Initial commit: VMware vSphere API simulator scaffold.

Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API
contracts, docs, client examples, and the unit/integration/compatibility
test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.venv
__pycache__
.mypy_cache
.pytest_cache
.ruff_cache
.env
htmlcov
docs
+33
View File
@@ -0,0 +1,33 @@
APP_HOST=0.0.0.0
# Internal uvicorn port (not published). Public vCenter HTTPS is on api-gateway :443.
APP_PORT=8080
DATABASE_URL=postgresql://vmware:vmware@postgres:5432/vmware_simulator
TEST_DATABASE_URL=postgresql://vmware:vmware@postgres:5432/vmware_simulator
DB_POOL_MIN_SIZE=1
DB_POOL_MAX_SIZE=10
DB_CONNECT_TIMEOUT_SECONDS=10
DB_COMMAND_TIMEOUT_SECONDS=30
LOG_LEVEL=INFO
REQUEST_ID_HEADER=X-Request-ID
VSPHERE_RELEASE=8.0 U2
# Native vSphere /api + /sdk is the default plane.
ENABLE_PVE_STUB=false
# vSphere synthetic inventory (default large ≈ 10 hosts / 1000 VMs)
SEED_VSPHERE_PROFILE=large
SEED_VSPHERE_LARGE_HOSTS=10
SEED_VSPHERE_LARGE_VMS=1000
# Optional Proxmox stub plane (only when ENABLE_PVE_STUB=true):
# 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=stub://vmware/vsphere-7.0/api-contract
CATALOG_ARTIFACT_URL_7=stub://vmware/vsphere-7.0u3/api-contract
CATALOG_ARTIFACT_URL_8=stub://vmware/vsphere-8.0/api-contract
CATALOG_ARTIFACT_URL_9=stub://vmware/vsphere-8.0u2/api-contract
TICKET_SIGNING_KEY=development-only-signing-key-change-me
TASK_WORKER_CONCURRENCY=2
TASK_LEASE_SECONDS=30
SIMULATION_SEED=42
SIMULATION_TIME_SCALE=10
SIMULATOR_ADMIN_ENABLED=false
SIMULATOR_ADMIN_TOKEN=replace-with-a-long-random-secret
+80
View File
@@ -0,0 +1,80 @@
# 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
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg
*.egg-info/
.eggs/
dist/
build/
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
# Terraform local state (never commit)
*.tfstate
*.tfstate.*
.terraform/
# Lab probe output (regenerated by scripts/probe_api_surface.py)
evidence/_api_surface_probe.json
+58
View File
@@ -0,0 +1,58 @@
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
VIRTUAL_ENV=/opt/venv
RUN python -m venv "$VIRTUAL_ENV"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /build
COPY pyproject.toml README.md ./
COPY app ./app
RUN pip install --upgrade "pip>=25.1,<26" && pip install .
FROM python:3.13-slim AS runtime
ARG APP_VERSION=0.1.0
LABEL org.opencontainers.image.title="vmware-api-simulator" \
org.opencontainers.image.version="$APP_VERSION" \
org.opencontainers.image.source="https://github.com/inecs/vmware-api-simulator"
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_HOST=0.0.0.0 \
APP_PORT=8080
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
# Optional legacy PVE stub plane only (ENABLE_PVE_STUB=true). Native vSphere
# contracts live under contracts/vsphere/ and app/vsphere/.
COPY evidence/ /app/evidence/
WORKDIR /app
USER 10001:10001
# Internal listen only — public vCenter HTTPS is on api-gateway.
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"]
ENTRYPOINT ["uvicorn", "app.main:app"]
CMD ["--host", "0.0.0.0", "--port", "8080"]
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"]
+13
View File
@@ -0,0 +1,13 @@
Copyright 2026 vmware-api-simulator contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+256
View File
@@ -0,0 +1,256 @@
COMPOSE ?= docker compose
SERVICE_DEV := dev
SERVICE_SIM := simulator
PYTEST_OFFLINE := -m "not integration and not compatibility and not pve_stub"
# Docker Hub release image (runtime target only — not the local bind-mount "dev" image).
DOCKERHUB_USER ?= inecs
IMAGE_NAME ?= vmware-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/vmware-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 \
pulumi-tests pulumi-tests-smoke test-pulumi-smoke test-pulumi test-smoke-all test-all clean-test-resources
help: ## Show available commands
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install: ## Build runtime and development images
@test -f .env || cp .env.example .env
$(COMPOSE) build simulator $(SERVICE_DEV)
format: ## Format Python sources
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) ruff format .
lint: ## Run Ruff lint checks
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) ruff check .
typecheck: ## Run strict mypy checks
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) mypy
test: ## Run offline unit and contract tests
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest $(PYTEST_OFFLINE)
test-unit: ## Run unit tests
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest tests/unit
test-integration: ## Run tests that require PostgreSQL
@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
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest -m contract
test-compatibility: ## Run client smoke flow against the Compose stack
@test -f .env || cp .env.example .env
$(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
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
vsphere-surface: ## Probe native vSphere REST coverage registry against running gateway
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --wait
# From the tools container, hit the simulator service (not host-mapped :443).
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_surface_probe.py
vsphere-matrix: ## Full REST matrix: all verbs × majors 69 (no 5xx)
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --wait
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_full_matrix_probe.py
vsphere-universe: ## Regenerate Broadcom Automation API universe.json from operations index
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python scripts/generate_vsphere_universe.py
vsphere-bundles: ## Regenerate stub OpenAPI matrices + evidence ledgers
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python scripts/write_vsphere_bundles.py
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python scripts/write_vsphere_evidence.py
test-vsphere: ## Native vSphere unit + integration + surface + majors matrix
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --wait
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest \
tests/unit/test_vsphere_profiles.py \
tests/unit/test_vsphere_catalog.py \
tests/unit/test_vsphere_matrix.py \
tests/unit/test_vsphere_compatibility.py \
tests/unit/test_vsphere_universe.py \
tests/unit/test_vsphere_mappers.py \
tests/unit/test_property_collector.py \
tests/unit/test_web_assets.py \
tests/unit/test_web_console.py \
tests/integration/test_vsphere_api.py \
tests/integration/test_vsphere_api_surface_data.py \
tests/integration/test_vsphere_soap_depth.py \
tests/integration/test_vsphere_soap_create_vm.py \
tests/integration/test_vsphere_full_api.py -q
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_surface_probe.py
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_full_matrix_probe.py
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_real_data_spotcheck.py
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/vsphere_nonempty_probe.py
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/run_client_cookbooks.py
client-cookbooks: ## Python/Ansible/Terraform/Pulumi-style cookbooks against gateway or simulator
@test -f .env || cp .env.example .env
$(COMPOSE) up -d --wait
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.simulation.seed_cli
$(COMPOSE) run --rm --no-deps -e VSPHERE_BASE=http://simulator:8080 $(SERVICE_DEV) \
python scripts/run_client_cookbooks.py
# Hybrid pulumi-tests suite (pulumi-vsphere + REST matrix + CRUD + SOAP)
pulumi-tests: ## Run full hybrid suite (provider + REST×6-9 + CRUD + SOAP)
@$(MAKE) -C pulumi-tests test-pulumi
pulumi-tests-smoke: ## PU-INV + one-major REST smoke (no VM/tags/CRUD/SOAP)
@$(MAKE) -C pulumi-tests test-pulumi-smoke
test-pulumi-smoke test-pulumi test-smoke-all test-all clean-test-resources: ## Aliases → pulumi-tests/
@$(MAKE) -C pulumi-tests $@
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
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 api-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
@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
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) vmware-api-contract import $(ARGS)
api-diff: ## Compare API snapshots
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) vmware-api-contract diff $(ARGS)
seed: ## Seed simulation data (vSphere; PROFILE= / VSPHERE_PROFILE=small|large|demo-cluster)
@test -f .env || cp .env.example .env
SEED_PROFILE="$${PROFILE:-small}" \
SEED_VSPHERE_PROFILE="$${VSPHERE_PROFILE:-$${PROFILE:-large}}" \
SEED_VSPHERE_LARGE_VMS="$${VSPHERE_VMS:-1000}" \
SEED_VSPHERE_LARGE_HOSTS="$${VSPHERE_HOSTS:-10}" \
$(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 .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
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 + client compatibility
$(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}" \
SEED_VSPHERE_PROFILE="$${VSPHERE_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 vmware-sim $(HELM_CHART) \
-f $(HELM_CHART)/values-ingress-example.yaml \
--set certManager.email=docs@example.com \
--set secret.ticketSigningKey=docs-only-signing-key
+228
View File
@@ -0,0 +1,228 @@
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
# vmware-api-simulator
Stateful asynchronous [VMware vSphere](https://www.vmware.com/products/vsphere.html)
API simulator for testing API clients and infrastructure tooling without a real
ESXi/vCenter cluster.
The simulator is backed by PostgreSQL and exposes native vCenter surfaces:
**REST** Automation API (`/api`, legacy `/rest`) and **SOAP** VIM/PBM (`/sdk`).
Semantic handlers persist inventory, sessions, tasks, tags, content libraries,
and permissions; power/clone/relocate/snapshot operations run as durable CIS
tasks with real task ids.
## Verified API coverage
Coverage is tracked against the public
[vSphere Automation API operations index](https://developer.broadcom.com/xapis/vsphere-automation-api/latest/operation-index/)
(~1037 unique verb+path routes in the simulator registry).
**Two layers (read this before the table):**
| Layer | Share (major 9) | Meaning |
|---|---:|---|
| Core deep handlers | ~104 routes (~10%) | Inventory, VM lifecycle, tasks, tagging, content library, appliance, authz — real PostgreSQL semantics |
| DB-backed stub surface | remaining registry (~90%) | Seeded non-empty JSON for the rest of the Broadcom route table (lab stand-ins, not production parity) |
| Catalog major | vSphere label | Catalog floor / universe | Floor coverage |
|---|---|---:|---:|
| 6 | 7.0 | 31 / 1077 | 2.9% |
| 7 | 7.0 U3 | 77 / 1077 | 7.2% |
| 8 | 8.0 | 103 / 1077 | 9.6% |
| 9 | 8.0 U2 (Automation 9.1 surface) | **1077 / 1077** | **100% route registry** |
At major 9 the **full route registry** is served (no known path 501s): deep
handlers plus stubs. Hot-swap (`POST /ui/api/contract/apply?major=N`) only
changes the **catalog** major used by the Web UI / evidence reports. See
[Compatibility](docs/compatibility.md), [compatibility 0.1.0](docs/compatibility-0.1.0.md),
and [API coverage](docs/api-coverage.md).
> This is measurable route-registry and handler coverage for a laboratory
> simulator — not a claim that every vSphere edge case or ESXi-hardware
> behavior is reproduced identically to production vCenter.
## Quick start (published image)
Image: [`inecs/vmware-api-simulator`](https://hub.docker.com/r/inecs/vmware-api-simulator)
Requires a git checkout of this repository (Compose mounts
`docker/gateway/` and `docker/tls/` next to the compose file).
### Docker Compose
```bash
docker compose -f docker-compose.release.yml up -d --wait
# seed runs automatically; re-run manually if you wiped the DB:
# docker compose -f docker-compose.release.yml run --rm --entrypoint python \
# simulator -m app.simulation.seed_cli
curl -sk https://localhost/health/ready
SID=$(curl -sk -u 'administrator@vsphere.local:VMware1!' -X POST \
https://localhost/api/session | tr -d '"')
curl -sk -H "vmware-api-session-id: $SID" https://localhost/api/vcenter/vm
```
Or: `make release-up` (seed is part of the release stack)
### Helm (Kubernetes + Ingress + Let's Encrypt)
```bash
helm upgrade --install vmware-sim ./helm/vmware-api-simulator \
-n vmware-sim --create-namespace \
-f ./helm/vmware-api-simulator/values-ingress-example.yaml \
--set certManager.email=you@example.com \
--set ingress.hosts[0].host=vmware-sim.example.com \
--set ingress.tls[0].hosts[0]=vmware-sim.example.com \
--set secret.ticketSigningKey="$(openssl rand -hex 32)"
```
Requires an Ingress controller and cert-manager. Details:
[Kubernetes / Helm](docs/kubernetes.md).
- Lab UI + REST (Compose gateway): [https://localhost/](https://localhost/)
- FastAPI schema docs: [https://localhost/docs](https://localhost/docs)
- Default seeded admin: `administrator@vsphere.local` / `VMware1!`
## Quick start (development checkout)
Build and run the bind-mounted development stack from this repository:
```bash
make install
make up
make seed PROFILE=small
curl -sk https://localhost/health/ready
curl -sk https://localhost/api/appliance/system/version
```
- HTTPS gateway (primary vCenter entry): `https://localhost`
- HTTP lab face: `http://localhost`
- PostgreSQL (localhost only): `5434`
- Internal FastAPI process (not published to the host): `8080`
- The checked-in `docker/tls/server.key` is a **lab-only** localhost cert; do
not reuse it outside local Compose.
- FastAPI schema docs: [https://localhost/docs](https://localhost/docs)
### Web UI
Interactive console with light/dark themes, endpoint catalog for vSphere
majors 69, request/response editing, 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)
## Credentials (seed)
Password `VMware1!` for all seeded principals:
| User | Role |
|---|---|
| `administrator@vsphere.local` | Administrator |
| `readonly@vsphere.local` | ReadOnly |
| `operator@vsphere.local` | VirtualMachinePowerUser |
| `vmadmin@vsphere.local` | VirtualMachineAdministrator |
## Documentation
Documentation is bilingual. Use the **Language / Язык** switcher at the top of
each page, or open the Russian root [README.ru.md](README.ru.md). Index:
[docs/README.md](docs/README.md) · [docs/ru/README.md](docs/ru/README.md).
| Guide | Description |
|---|---|
| [Getting started](docs/getting-started.md) | First successful lab session |
| [Configuration](docs/configuration.md) | Environment variables and Compose |
| [Authentication](docs/authentication.md) | Sessions, `vmware-api-session-id`, privileges |
| [API versions](docs/api-versions.md) | Catalog majors 69 and hot-swap |
| [API surface](docs/api-surface.md) | REST/SOAP routing, coverage registry, stubs |
| [API coverage](docs/api-coverage.md) | Broadcom universe vs implemented surface |
| [Clients & examples](docs/clients.md) | Python, Go, Java, Perl, Ansible, Terraform, Pulumi |
| [Seed profiles](docs/seed-profiles.md) | Deterministic inventory fixtures |
| [Domains](docs/domains/README.md) | Session, inventory, VM, storage, networking, tagging, SOAP, tasks, … |
| [Web UI](docs/web-ui.md) | Interactive console and catalogs |
| [Operations](docs/operations.md) | Reseed, migrate, release, 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 |
| [Ports](docs/ports.md) | Published host ports and internal services |
| [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). The
[`pulumi-vsphere`](https://www.pulumi.com/registry/packages/vsphere/) lab suite
(nonempty output checks, HTML report) lives under
[`pulumi-tests/`](pulumi-tests/README.md) — run with `make pulumi-tests`.
## Python (requests) against the HTTPS gateway
```python
import requests
requests.packages.urllib3.disable_warnings()
session = requests.post(
"https://localhost/api/session",
auth=("administrator@vsphere.local", "VMware1!"),
verify=False, # local self-signed development certificate only
)
headers = {"vmware-api-session-id": session.json()}
vms = requests.get("https://localhost/api/vcenter/vm", headers=headers, verify=False)
print(vms.json())
```
SOAP / VIM clients (pyvmomi, govmomi, `hashicorp/vsphere` Terraform provider,
Pulumi) point at `https://localhost/sdk` with the same credentials.
## Common Make targets
```bash
make up / make down / make logs / make dev
make seed # large vSphere seed (10 hosts / 1000 VMs)
VSPHERE_PROFILE=small make seed # compact inventory (3 hosts / 5 VMs)
make test # unit + contract (offline)
make test-vsphere # native vSphere unit + integration + surface + matrix
make vsphere-surface # probe REST coverage registry against the running gateway
make vsphere-matrix # full REST matrix: all verbs × majors 6-9 (no 5xx)
make evidence # regenerate evidence/vsphere-*.json ledgers
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
```
Docker Hub release (requires `docker login` as the Hub owner; see
[Operations](docs/operations.md)):
```bash
make release # inecs/vmware-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
```
## What this is not
- Not a hypervisor: no ESXi/KVM execution on bare metal or nested hosts.
- Not a drop-in multi-tenant production vCenter replacement.
- No Supervisor/Tanzu control plane, no NSX Manager, no deep vSAN, no
SAML/OIDC federation, no VECS certificate store — lab-shaped stand-ins
exist for some of these (seeded, non-binary-compatible data). HttpNfcLease
/ content-library transfer **handshakes** are implemented on `/nfc` and
related REST/SOAP paths, but not production-binary-compatible NFC uploads;
see [docs/api-coverage.md](docs/api-coverage.md).
- Remote IdP / LDAP / live NSX / live ACME directories are simulated locally;
they do not call real external systems.
- An optional legacy Proxmox VE stub plane exists behind `ENABLE_PVE_STUB`
(**off** by default) from a shared platform lineage; it is not the primary
surface of this project.
## License
Apache-2.0 — see [LICENSE](LICENSE).
+229
View File
@@ -0,0 +1,229 @@
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
# vmware-api-simulator
Stateful-асинхронный симулятор API [VMware vSphere](https://www.vmware.com/products/vsphere.html)
для тестирования API-клиентов и инфраструктурных инструментов без реального
кластера ESXi/vCenter.
Симулятор работает на PostgreSQL и предоставляет нативные поверхности vCenter:
**REST** Automation API (`/api`, legacy `/rest`) и **SOAP** VIM/PBM (`/sdk`).
Семантические обработчики сохраняют инвентарь, сессии, задачи, теги, content library
и права доступа; операции power/clone/relocate/snapshot выполняются как устойчивые
CIS-задачи с реальными id задач.
## Проверенное покрытие API
Покрытие отслеживается относительно публичного
[vSphere Automation API operations index](https://developer.broadcom.com/xapis/vsphere-automation-api/latest/operation-index/)
(~1037 уникальных маршрутов verb+path в registry симулятора).
**Два слоя (прочитайте до таблицы):**
| Слой | Доля (major 9) | Смысл |
|---|---:|---|
| Core deep handlers | ~104 маршрута (~10%) | Инвентарь, lifecycle ВМ, tasks, tagging, content library, appliance, authz — реальная семантика в PostgreSQL |
| DB-backed stub surface | остальной registry (~90%) | Засеянный non-empty JSON по остальной таблице Broadcom (lab stand-in, не production parity) |
| Catalog major | Метка vSphere | Catalog floor / universe | Floor coverage |
|---|---|---:|---:|
| 6 | 7.0 | 31 / 1077 | 2.9% |
| 7 | 7.0 U3 | 77 / 1077 | 7.2% |
| 8 | 8.0 | 103 / 1077 | 9.6% |
| 9 | 8.0 U2 (Automation 9.1 surface) | **1077 / 1077** | **100% route registry** |
На major 9 обслуживается **полный route registry** (нет известных path 501):
deep handlers плюс stubs. Hot-swap (`POST /ui/api/contract/apply?major=N`)
меняет только **catalog** major для Web UI / evidence-отчётов. См.
[Совместимость](docs/ru/compatibility.md),
[compatibility 0.1.0](docs/ru/compatibility-0.1.0.md) и
[Покрытие API](docs/ru/api-coverage.md).
> Это измеримое покрытие route-registry и обработчиков лабораторного симулятора —
> не утверждение, что каждый краевой случай vSphere или поведение ESXi-железа
> воспроизводится идентично продакшен-vCenter.
## Быстрый старт (опубликованный образ)
Образ: [`inecs/vmware-api-simulator`](https://hub.docker.com/r/inecs/vmware-api-simulator)
Нужен git checkout этого репозитория (Compose монтирует `docker/gateway/` и
`docker/tls/` рядом с compose-файлом).
### Docker Compose
```bash
docker compose -f docker-compose.release.yml up -d --wait
# seed выполняется автоматически; при очистке БД:
# docker compose -f docker-compose.release.yml run --rm --entrypoint python \
# simulator -m app.simulation.seed_cli
curl -sk https://localhost/health/ready
SID=$(curl -sk -u 'administrator@vsphere.local:VMware1!' -X POST \
https://localhost/api/session | tr -d '"')
curl -sk -H "vmware-api-session-id: $SID" https://localhost/api/vcenter/vm
```
Или: `make release-up` (seed входит в release-стек)
### Helm (Kubernetes + Ingress + Let's Encrypt)
```bash
helm upgrade --install vmware-sim ./helm/vmware-api-simulator \
-n vmware-sim --create-namespace \
-f ./helm/vmware-api-simulator/values-ingress-example.yaml \
--set certManager.email=you@example.com \
--set ingress.hosts[0].host=vmware-sim.example.com \
--set ingress.tls[0].hosts[0]=vmware-sim.example.com \
--set secret.ticketSigningKey="$(openssl rand -hex 32)"
```
Нужны Ingress-контроллер и cert-manager. Подробности:
[Kubernetes / Helm](docs/ru/kubernetes.md).
- Lab UI + REST (Compose gateway): [https://localhost/](https://localhost/)
- Схема FastAPI: [https://localhost/docs](https://localhost/docs)
- Админ по умолчанию после seed: `administrator@vsphere.local` / `VMware1!`
## Быстрый старт (разработка из репозитория)
Сборка и запуск development-стека с bind-mount из этого репозитория:
```bash
make install
make up
make seed PROFILE=small
curl -sk https://localhost/health/ready
curl -sk https://localhost/api/appliance/system/version
```
- HTTPS gateway (основная точка входа vCenter): `https://localhost`
- HTTP lab face: `http://localhost`
- PostgreSQL (только localhost): `5434`
- Внутренний процесс FastAPI (не публикуется на хост): `8080`
- Вшитый `docker/tls/server.key`**только для лаборатории** localhost-сертификат;
не используйте его вне локального Compose.
- Схема FastAPI: [https://localhost/docs](https://localhost/docs)
### Web UI
Интерактивная консоль со светлой/тёмной темой, каталог эндпоинтов для vSphere
majors 6–9, редактирование request/response и runtime contract hot-swap. Подробнее:
[Web UI](docs/ru/web-ui.md).
![Web UI light theme](docs/images/web-ui-light.png)
![Web UI dark theme](docs/images/web-ui-dark.png)
## Учётные данные (seed)
Пароль `VMware1!` для всех засеянных principals:
| User | Role |
|---|---|
| `administrator@vsphere.local` | Administrator |
| `readonly@vsphere.local` | ReadOnly |
| `operator@vsphere.local` | VirtualMachinePowerUser |
| `vmadmin@vsphere.local` | VirtualMachineAdministrator |
## Документация
Документация двуязычная. Используйте переключатель **Language / Язык** в начале
каждой страницы или откройте русский корень [README.ru.md](README.ru.md). Индекс:
[docs/README.md](docs/README.md) · [docs/ru/README.md](docs/ru/README.md).
| Руководство | Описание |
|---|---|
| [Быстрый старт](docs/ru/getting-started.md) | Первая успешная лабораторная сессия |
| [Конфигурация](docs/ru/configuration.md) | Переменные окружения и Compose |
| [Аутентификация](docs/ru/authentication.md) | Сессии, `vmware-api-session-id`, привилегии |
| [Версии API](docs/ru/api-versions.md) | Catalog majors 69 и hot-swap |
| [Поверхность API](docs/ru/api-surface.md) | Маршрутизация REST/SOAP, coverage registry, stubs |
| [Покрытие API](docs/ru/api-coverage.md) | Broadcom universe vs реализованная поверхность |
| [Клиенты и примеры](docs/ru/clients.md) | Python, Go, Java, Perl, Ansible, Terraform, Pulumi |
| [Профили seed](docs/ru/seed-profiles.md) | Детерминированные фикстуры инвентаря |
| [Домены](docs/ru/domains/README.md) | Session, inventory, VM, storage, networking, tagging, SOAP, tasks, … |
| [Web UI](docs/ru/web-ui.md) | Интерактивная консоль и каталоги |
| [Эксплуатация](docs/ru/operations.md) | Reseed, migrate, release, upgrade |
| [Kubernetes / Helm](docs/ru/kubernetes.md) | Образ Hub + Ingress + Let's Encrypt |
| [Безопасность](docs/ru/security.md) | Модель угроз лаборатории и учётные данные |
| [Наблюдаемость](docs/ru/observability.md) | Эндпоинты health и логирование |
| [Порты](docs/ru/ports.md) | Опубликованные порты хоста и внутренние сервисы |
| [Устранение неполадок](docs/ru/troubleshooting.md) | Типичные сбои |
| [FAQ](docs/ru/faq.md) | Краткие ответы |
| [Архитектура](docs/ru/architecture.md) | Границы компонентов |
| [Совместимость](docs/ru/compatibility.md) | Модель evidence и матрица релизов |
Исполняемые cookbook'и находятся в [`examples/`](examples/README.ru.md). Lab-набор
на официальном [`pulumi-vsphere`](https://www.pulumi.com/registry/packages/vsphere/)
(проверки непустых export'ов, HTML-отчёт) — в
[`pulumi-tests/`](pulumi-tests/README.ru.md); запуск: `make pulumi-tests`.
## Python (requests) через HTTPS gateway
```python
import requests
requests.packages.urllib3.disable_warnings()
session = requests.post(
"https://localhost/api/session",
auth=("administrator@vsphere.local", "VMware1!"),
verify=False, # local self-signed development certificate only
)
headers = {"vmware-api-session-id": session.json()}
vms = requests.get("https://localhost/api/vcenter/vm", headers=headers, verify=False)
print(vms.json())
```
SOAP / VIM клиенты (pyvmomi, govmomi, Terraform provider `hashicorp/vsphere`,
Pulumi) указывают на `https://localhost/sdk` с теми же учётными данными.
## Основные Make-цели
```bash
make up / make down / make logs / make dev
make seed # large vSphere seed (10 hosts / 1000 VMs)
VSPHERE_PROFILE=small make seed # compact inventory (3 hosts / 5 VMs)
make test # unit + contract (offline)
make test-vsphere # native vSphere unit + integration + surface + matrix
make vsphere-surface # probe REST coverage registry against the running gateway
make vsphere-matrix # full REST matrix: all verbs × majors 6-9 (no 5xx)
make evidence # regenerate evidence/vsphere-*.json ledgers
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
```
Docker Hub release (нужен `docker login` как владелец Hub; см.
[Эксплуатация](docs/ru/operations.md)):
```bash
make release # inecs/vmware-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
```
## Чем это не является
- Не гипервизор: нет выполнения ESXi/KVM на bare metal или nested hosts.
- Не drop-in multi-tenant production vCenter replacement.
- Нет Supervisor/Tanzu control plane, NSX Manager, deep vSAN, SAML/OIDC federation,
VECS certificate store — для некоторых из них есть lab-shaped stand-ins
(засеянные, non-binary-compatible данные). Handshake HttpNfcLease /
content-library transfer реализован на `/nfc` и связанных REST/SOAP-путях,
но не production-binary-compatible NFC uploads; см.
[docs/ru/api-coverage.md](docs/ru/api-coverage.md).
- Удалённые IdP / LDAP / live NSX / live ACME directories симулируются локально;
они не обращаются к реальным внешним системам.
- Опциональная legacy Proxmox VE stub-плоскость доступна за `ENABLE_PVE_STUB`
(**выключена** по умолчанию) из общей platform lineage; это не основная
поверхность проекта.
## Лицензия
Apache-2.0 — см. [LICENSE](LICENSE).
+1
View File
@@ -0,0 +1 @@
"""Proxmox API simulator application package."""
+1
View File
@@ -0,0 +1 @@
"""HTTP adapters."""
+51
View File
@@ -0,0 +1,51 @@
"""Base external error representation."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import Request
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
class ApiError(Exception):
"""A safe error intended for the Proxmox-compatible boundary."""
def __init__(
self, status_code: int, message: str, errors: dict[str, str] | None = None
) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.errors = errors
class ContractValidationError(ApiError):
def __init__(self, errors: dict[str, str]) -> None:
super().__init__(400, "parameter verification failed", errors)
async def api_error_handler(_request: Request, exc: Exception) -> JSONResponse:
if not isinstance(exc, ApiError):
raise TypeError("api_error_handler received an incompatible exception")
body: dict[str, Any] = {"data": None, "message": exc.message}
if exc.errors is not None:
body["errors"] = exc.errors
return JSONResponse(status_code=exc.status_code, content=body)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Log internal failures and return a stable non-FastAPI error envelope."""
logger.exception(
"unhandled request error",
extra={"request_id": getattr(request.state, "request_id", None), "path": request.url.path},
)
body: dict[str, Any] = {
"data": None,
"errors": {"internal": "internal server error"},
}
return JSONResponse(status_code=500, content=body)
+42
View File
@@ -0,0 +1,42 @@
"""Request correlation and access logging middleware."""
from __future__ import annotations
import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
RequestHandler = Callable[[Request], Awaitable[Response]]
class RequestContextMiddleware(BaseHTTPMiddleware):
"""Attach a bounded request ID and log one structured completion event."""
def __init__(self, app: object, header_name: str) -> None:
super().__init__(app) # type: ignore[arg-type]
self._header_name = header_name
async def dispatch(self, request: Request, call_next: RequestHandler) -> Response:
supplied = request.headers.get(self._header_name, "")
request_id = supplied if 0 < len(supplied) <= 128 else str(uuid.uuid4())
request.state.request_id = request_id
started = time.monotonic()
response = await call_next(request)
response.headers[self._header_name] = request_id
logger.info(
"request completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"duration_ms": round((time.monotonic() - started) * 1000, 3),
},
)
return response
+138
View File
@@ -0,0 +1,138 @@
"""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",
}
_VSPHERE_TAG_DESCRIPTIONS: dict[str, str] = {
"vSphere REST": "vSphere Automation REST inventory and lifecycle APIs.",
"vSphere REST surface": "Additional vSphere REST surface stubs.",
"vSphere SOAP": "vSphere Web Services (SOAP) SDK endpoints.",
"vSphere PBM": "Storage Policy Based Management (PBM) SOAP endpoints.",
"vSphere Platform": "Appliance, CIS session, and platform helpers.",
"vSphere Tagging": "CIS tagging categories and tags.",
"vSphere Content": "Content library stubs.",
"vSphere NFC": "NFC file transfer stubs.",
"vSphere Tasks": "vSphere task polling helpers.",
"vSphere VM Ext": "Extended VM operations beyond the core REST surface.",
"vSphere Inventory Ext": "Extended inventory and folder helpers.",
"vSphere Appliance": "vCenter appliance management stubs.",
"vSphere Legacy REST": "Legacy vSphere REST compatibility stubs.",
}
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 _pve_openapi_tag_descriptions() -> dict[str, str]:
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.",
}
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 descriptions
def openapi_tag_metadata(*, include_pve: bool = False) -> list[dict[str, str]]:
"""Descriptions shown in Swagger UI for each tag group.
Proxmox `/api2/*` tag groups are omitted unless ``include_pve`` is true,
so the default vSphere plane does not show empty legacy sections in `/docs`.
"""
descriptions: dict[str, str] = {
"Simulator": "Health checks, compatibility reports, and the web console.",
**_VSPHERE_TAG_DESCRIPTIONS,
}
if include_pve:
descriptions.update(_pve_openapi_tag_descriptions())
return [{"name": name, "description": text} for name, text in sorted(descriptions.items())]
+375
View File
@@ -0,0 +1,375 @@
"""Contract-driven dynamic route and semantic handler registry."""
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
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
from app.security.auth import parse_api_token, verify_csrf, verify_secret, verify_ticket
Handler = Callable[[Request, dict[str, Any]], Awaitable[Any]]
FallbackMode = Literal["error", "schema-default", "fixture"]
class RouteCollisionError(ValueError):
pass
@dataclass(slots=True)
class HandlerRegistry:
_handlers: dict[tuple[str, str], Handler] = field(default_factory=dict)
def register(self, path: str, verb: str, handler: Handler) -> None:
key = (path, verb.upper())
if key in self._handlers:
raise RouteCollisionError(f"duplicate semantic handler: {verb} {path}")
self._handlers[key] = handler
def get(self, path: str, verb: str) -> Handler | None:
return self._handlers.get((path, verb.upper()))
def keys(self) -> frozenset[tuple[str, str]]:
return frozenset(self._handlers)
def register_contract_routes(
app: FastAPI,
snapshot: Snapshot,
handlers: HandlerRegistry,
fallback: FallbackMode = "error",
*,
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,
renderer,
handlers,
fallback,
)
app.add_api_route(
route,
endpoint,
methods=[contract_method.verb],
name=f"contract:{renderer}:{contract_method.verb}:{contract_path.path}",
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(
semantic_path: str,
method: Method,
renderer: str,
handlers: HandlerRegistry,
fallback: FallbackMode,
) -> Callable[[Request], Awaitable[JSONResponse]]:
async def dispatch(request: Request) -> JSONResponse:
inputs = await _parse_inputs(request, method)
await _authenticate(request, semantic_path, method, inputs)
handler = handlers.get(semantic_path, method.verb)
if handler is not None:
data = await handler(request, inputs)
elif fallback == "schema-default":
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": "handler pending for this contract method"},
)
content = {"data": data, "success": True} if renderer == "extjs" else {"data": data}
response = JSONResponse(content)
if semantic_path == "/access/ticket" and isinstance(data, dict):
ticket = data.get("ticket")
if isinstance(ticket, str):
response.set_cookie(
"PVEAuthCookie", ticket, httponly=True, samesite="strict", path="/"
)
return response
return dispatch
async def _authenticate(
request: Request, semantic_path: str, method: Method, inputs: dict[str, Any]
) -> None:
if semantic_path in {"/version", "/access/ticket"}:
return
authorization = request.headers.get("Authorization", "")
token_privileges: frozenset[str] | None = None
principal: str
if authorization.startswith("PVEAPIToken="):
database = cast(AsyncpgDatabase, request.app.state.database)
try:
parsed_token = parse_api_token(authorization)
except ValueError as error:
raise ApiError(401, "authentication failure") from error
row = await database.pool.fetchrow(
"""SELECT p.name, t.secret_hash, t.privileges, t.privilege_separation
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
WHERE p.name=$1 AND t.token_id=$2
AND (t.expires_at IS NULL OR t.expires_at > now())""",
parsed_token.principal,
parsed_token.token_id,
)
if row is None or not verify_secret(parsed_token.secret, str(row["secret_hash"])):
raise ApiError(401, "authentication failure")
principal = str(row["name"])
token_privileges = (
frozenset(str(item) for item in row["privileges"])
if bool(row["privilege_separation"])
else None
)
else:
ticket = request.cookies.get("PVEAuthCookie")
if ticket is None:
raise ApiError(401, "authentication required")
settings = cast(Settings, request.app.state.settings)
key = settings.ticket_signing_key.get_secret_value().encode()
try:
claims = verify_ticket(ticket, key)
except ValueError as error:
raise ApiError(401, "authentication failure") from error
principal = claims.principal
if request.method not in {"GET", "HEAD", "OPTIONS"}:
csrf_value = request.headers.get("CSRFPreventionToken", "")
if not verify_csrf(ticket, csrf_value, key):
raise ApiError(403, "invalid CSRF prevention token")
request.state.principal = principal
if principal == "root@pam" and token_privileges is None:
return
database = cast(AsyncpgDatabase, request.app.state.database)
await _authorize(database, principal, token_privileges, semantic_path, method, inputs)
async def _authorize(
database: AsyncpgDatabase,
principal: str,
token_privileges: frozenset[str] | None,
semantic_path: str,
method: Method,
inputs: dict[str, Any],
) -> None:
values = cast(dict[str, Any], inputs["values"])
requirement = requirement_from_contract(
method.permissions, {name: str(value) for name, value in values.items()}
)
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(
"""SELECT a.path, a.propagate, r.privileges
FROM acl_entries a JOIN roles r ON r.name=a.role_name
JOIN principals p ON p.id=a.principal_id WHERE p.name=$1
UNION ALL
SELECT a.path, a.propagate, r.privileges
FROM group_acl_entries a JOIN roles r ON r.name=a.role_name
JOIN identity_group_members m ON m.group_id=a.group_id
JOIN principals p ON p.id=m.principal_id WHERE p.name=$1""",
principal,
)
entries = tuple(
AclEntry(
principal,
str(row["path"]),
frozenset(str(item) for item in row["privileges"]),
bool(row["propagate"]),
)
for row in rows
)
if not authorize(
principal,
requirement.path,
requirement.privileges,
entries,
token_privileges=token_privileges,
require_all=requirement.require_all,
):
raise ApiError(403, "permission check failed")
async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
supplied: dict[str, Any] = dict(request.query_params)
supplied.update(request.path_params)
if request.method not in {"GET", "DELETE"}:
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip()
if content_type == "application/json":
try:
body = await request.json()
except ValueError as exc:
raise ContractValidationError({"body": "invalid JSON"}) from exc
if not isinstance(body, dict):
raise ContractValidationError({"body": "expected an object"})
supplied.update(body)
elif content_type == "application/x-www-form-urlencoded":
supplied.update(dict(parse_qsl((await request.body()).decode())))
definitions = {parameter.name: parameter.definition for parameter in method.parameters}
indexed = {
re.compile("^" + re.escape(name).replace(r"\[n\]", r"\d+") + "$"): definition
for name, definition in definitions.items()
if "[n]" in name
}
errors: dict[str, str] = {}
parsed: dict[str, Any] = {}
for name, definition in definitions.items():
if "[n]" in name:
continue
if name not in supplied:
if definition.optional:
if definition.default is not None:
parsed[name] = definition.default
continue
errors[name] = "property is missing and it is not optional"
continue
try:
parsed[name] = _coerce(supplied[name], definition)
except (TypeError, ValueError) as exc:
errors[name] = str(exc)
indexed_names: set[str] = set()
for name in supplied.keys() - definitions.keys():
indexed_definition = next(
(candidate for pattern, candidate in indexed.items() if pattern.fullmatch(name)), None
)
if indexed_definition is not None:
indexed_names.add(name)
try:
parsed[name] = _coerce(supplied[name], indexed_definition)
except (TypeError, ValueError) as exc:
errors[name] = str(exc)
for name in supplied.keys() - definitions.keys() - indexed_names:
if name not in request.path_params:
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),
"provided": tuple(sorted(supplied)),
}
def _coerce(value: Any, schema: Schema) -> Any:
if schema.type == "integer":
parsed: Any = int(value)
elif schema.type == "number":
parsed = float(value)
elif schema.type == "boolean":
if isinstance(value, bool):
parsed = value
elif str(value).lower() in {"1", "true", "yes", "on"}:
parsed = True
elif str(value).lower() in {"0", "false", "no", "off"}:
parsed = False
else:
raise ValueError("expected a boolean")
elif schema.type == "string" or schema.type is None:
parsed = str(value)
else:
parsed = value
if schema.enum and parsed not in schema.enum:
raise ValueError("value is not in the allowed enumeration")
if isinstance(parsed, int | float):
if schema.minimum is not None and parsed < schema.minimum:
raise ValueError(f"value must be at least {schema.minimum}")
if schema.maximum is not None and parsed > schema.maximum:
raise ValueError(f"value must be at most {schema.maximum}")
if isinstance(parsed, str):
if schema.min_length is not None and len(parsed) < schema.min_length:
raise ValueError(f"value is shorter than {schema.min_length}")
if schema.max_length is not None and len(parsed) > schema.max_length:
raise ValueError(f"value is longer than {schema.max_length}")
return parsed
def _schema_default(schema: Schema) -> Any:
return schema_example(schema)
+311
View File
@@ -0,0 +1,311 @@
"""Evidence-based compatibility accounting and reporting."""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
from html import escape
from pathlib import Path
from types import MappingProxyType
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from app.contracts.model import Snapshot
MethodKey = tuple[str, str]
class CompatibilityDimension(StrEnum):
ROUTE_METHOD = "route_method"
INPUT_PARAMETERS = "input_parameters"
PARAMETER_REQUIREDNESS = "parameter_requiredness"
TYPES_CONSTRAINTS = "types_constraints"
HTTP_STATUS = "http_status"
JSON_STRUCTURE = "json_structure"
RESPONSE_FIELD_TYPES = "response_field_types"
RESPONSE_REQUIRED_FIELDS = "response_required_fields"
HEADERS_COOKIES = "headers_cookies"
STATE_SEMANTICS = "state_semantics"
LONG_TASK_BEHAVIOR = "long_task_behavior"
ERRORS_PROHIBITIONS = "errors_prohibitions"
PERMISSIONS = "permissions"
EMPTY_DIMENSION_EVIDENCE: Mapping[CompatibilityDimension, frozenset[MethodKey]] = MappingProxyType(
{dimension: frozenset() for dimension in CompatibilityDimension}
)
class MethodEvidence(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
path: str
verb: str
dimensions: tuple[CompatibilityDimension, ...]
sources: tuple[str, ...]
observed: bool = True
verified: bool = True
@field_validator("sources")
@classmethod
def require_sources(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if not value:
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")
format_version: int = 1
profile: str
source_version: str
records: tuple[MethodEvidence, ...]
@model_validator(mode="after")
def reject_duplicate_methods(self) -> EvidenceManifest:
keys = [(record.path, record.verb) for record in self.records]
if len(keys) != len(set(keys)):
raise ValueError("evidence manifest contains duplicate methods")
return self
def dimension_map(self) -> Mapping[CompatibilityDimension, frozenset[MethodKey]]:
evidence: dict[CompatibilityDimension, set[MethodKey]] = {
dimension: set() for dimension in CompatibilityDimension
}
for record in self.records:
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
declared: frozenset[MethodKey]
schema_only: frozenset[MethodKey]
implemented: frozenset[MethodKey]
observed: frozenset[MethodKey]
verified: frozenset[MethodKey]
dimensions: Mapping[CompatibilityDimension, frozenset[MethodKey]]
incompatible: frozenset[MethodKey]
regressions: frozenset[MethodKey]
def as_json(self) -> dict[str, object]:
levels = {
"declared": self.declared,
"schema_only": self.schema_only,
"implemented": self.implemented,
"observed": self.observed,
"verified": self.verified,
}
total = len(self.declared)
dimension_sets = tuple(self.dimensions.values())
fully_evidenced = (
dimension_sets[0].intersection(*dimension_sets[1:]) if dimension_sets else frozenset()
)
evidenced = frozenset().union(*dimension_sets)
fully_compatible = fully_evidenced & self.implemented
partially_compatible = (evidenced & self.implemented) - fully_compatible - self.incompatible
return {
"source_version": self.source_version,
"total_declared": total,
"levels": {
name: {
"count": len(methods),
"score": len(methods) / total if total else 1.0,
"methods": [f"{verb} {path}" for path, verb in sorted(methods)],
}
for name, methods in levels.items()
},
"groups": self._groups(),
"dimension_groups": self._dimension_groups(),
"classifications": {
"fully_compatible": self._method_names(fully_compatible),
"partially_compatible": self._method_names(partially_compatible),
"incompatible": self._method_names(self.incompatible),
"regressions": self._method_names(self.regressions),
"unsupported": self._method_names(self.schema_only),
},
"dimensions": {
dimension.value: {
"count": len(methods),
"score": len(methods) / total if total else 1.0,
"methods": [f"{verb} {path}" for path, verb in sorted(methods)],
}
for dimension, methods in self.dimensions.items()
},
}
@staticmethod
def _method_names(methods: frozenset[MethodKey]) -> list[str]:
return [f"{verb} {path}" for path, verb in sorted(methods)]
def canonical_json(self) -> str:
return json.dumps(self.as_json(), ensure_ascii=False, separators=(",", ":"), sort_keys=True)
def _groups(self) -> dict[str, dict[str, int]]:
groups: dict[str, dict[str, int]] = {}
for path, verb in self.declared:
group = path.strip("/").split("/", 1)[0] or "root"
counters = groups.setdefault(group, {"declared": 0, "implemented": 0, "verified": 0})
counters["declared"] += 1
counters["implemented"] += int((path, verb) in self.implemented)
counters["verified"] += int((path, verb) in self.verified)
return dict(sorted(groups.items()))
def _dimension_groups(self) -> dict[str, dict[str, int]]:
groups: dict[str, dict[str, int]] = {}
for dimension, methods in self.dimensions.items():
for path, _verb in methods:
group = path.strip("/").split("/", 1)[0] or "root"
counters = groups.setdefault(
group, {item.value: 0 for item in CompatibilityDimension}
)
counters[dimension.value] += 1
return dict(sorted(groups.items()))
def as_markdown(self) -> str:
levels = {
"declared": self.declared,
"schema_only": self.schema_only,
"implemented": self.implemented,
"observed": self.observed,
"verified": self.verified,
}
total = len(self.declared)
lines = [
"# Compatibility report",
"",
"| Level | Count | Score |",
"|---|---:|---:|",
]
for name, methods in levels.items():
score = len(methods) / total if total else 1.0
lines.append(f"| {name} | {len(methods)} | {score:.2%} |")
lines.extend(
[
"",
"## Compatibility dimensions",
"",
"| Dimension | Verified methods | Score |",
"|---|---:|---:|",
]
)
for dimension, methods in self.dimensions.items():
score = len(methods) / total if total else 1.0
lines.append(f"| {dimension.value} | {len(methods)} | {score:.2%} |")
return "\n".join(lines)
def as_html(self) -> str:
rows = "".join(
"<tr>"
f"<td>{escape(dimension.value)}</td>"
f"<td>{len(methods)}</td>"
f"<td>{(len(methods) / len(self.declared) if self.declared else 1.0):.2%}</td>"
"</tr>"
for dimension, methods in self.dimensions.items()
)
return (
'<!doctype html><html lang="en"><meta charset="utf-8">'
"<title>Compatibility report</title><body>"
f"<h1>PVE {escape(self.source_version)} compatibility</h1>"
"<table><thead><tr><th>Dimension</th><th>Verified methods</th>"
f"<th>Score</th></tr></thead><tbody>{rows}</tbody></table></body></html>"
)
def build_report(
snapshot: Snapshot,
*,
implemented: frozenset[MethodKey] = frozenset(),
observed: frozenset[MethodKey] = frozenset(),
verified: frozenset[MethodKey] = frozenset(),
dimensions: Mapping[CompatibilityDimension, frozenset[MethodKey]] = EMPTY_DIMENSION_EVIDENCE,
incompatible: frozenset[MethodKey] = frozenset(),
regressions: frozenset[MethodKey] = frozenset(),
) -> CompatibilityReport:
declared = frozenset(
(path.path, method.verb.upper()) for path in snapshot.paths for method in path.methods
)
for name, evidence in {
"implemented": implemented,
"observed": observed,
"verified": verified,
"incompatible": incompatible,
"regressions": regressions,
}.items():
if not evidence <= declared:
raise ValueError(f"{name} evidence references undeclared methods")
resolved_dimensions = {
dimension: frozenset(dimensions.get(dimension, frozenset()))
for dimension in CompatibilityDimension
}
for dimension, evidence in resolved_dimensions.items():
if not evidence <= declared:
raise ValueError(f"{dimension.value} evidence references undeclared methods")
return CompatibilityReport(
source_version=snapshot.source_version,
declared=declared,
schema_only=declared - implemented,
implemented=implemented,
observed=observed,
verified=verified,
dimensions=MappingProxyType(resolved_dimensions),
incompatible=incompatible,
regressions=regressions,
)
+63
View File
@@ -0,0 +1,63 @@
"""Typed application configuration."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime settings loaded from environment variables and an optional `.env`."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
frozen=True,
)
app_name: str = "vmware-api-simulator"
app_host: str = "0.0.0.0" # noqa: S104 - the container must accept external traffic
# Internal listen port. Public vCenter HTTPS is published by api-gateway.
app_port: int = Field(default=8080, ge=1, le=65535)
database_url: SecretStr = SecretStr(
"postgresql://vmware:vmware@localhost:5432/vmware_simulator"
)
db_pool_min_size: int = Field(default=1, ge=1, le=100)
db_pool_max_size: int = Field(default=10, ge=1, le=100)
db_connect_timeout_seconds: float = Field(default=10.0, gt=0, le=60)
db_command_timeout_seconds: float = Field(default=30.0, gt=0, le=300)
log_level: str = "INFO"
request_id_header: str = "X-Request-ID"
# Proxmox /api2 stub plane is off by default — native vSphere /api + /sdk is primary.
enable_pve_stub: bool = False
contract_snapshot: Path | None = None
compatibility_evidence: Path | None = None
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
catalog_artifact_url_6: str = "stub://vmware/vsphere-7.0/api-contract"
catalog_artifact_url_7: str = "stub://vmware/vsphere-7.0u3/api-contract"
catalog_artifact_url_8: str = "stub://vmware/vsphere-8.0/api-contract"
catalog_artifact_url_9: str = "stub://vmware/vsphere-8.0u2/api-contract"
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:
"""Return the immutable process configuration."""
return Settings()
+1
View File
@@ -0,0 +1 @@
"""Authoritative API contract ingestion and normalization."""
+87
View File
@@ -0,0 +1,87 @@
"""Command-line interface for contract imports and inspection."""
from __future__ import annotations
import argparse
import asyncio
import json
from datetime import UTC, datetime
from pathlib import Path
from app.contracts.diff import (
compare_snapshots,
has_breaking_changes,
render_html,
render_json,
render_markdown,
render_text,
)
from app.contracts.importer import RemoteSourceImporter
from app.contracts.model import Snapshot
from app.contracts.normalize import normalize_snapshot
from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceImporter
from app.contracts.store import RevisionStore
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(prog="proxmox-api-contract")
root.add_argument("--store", type=Path, default=Path("contracts"))
commands = root.add_subparsers(dest="command", required=True)
import_command = commands.add_parser("import")
source = import_command.add_mutually_exclusive_group(required=True)
source.add_argument("--file", type=Path)
source.add_argument("--url")
import_command.add_argument("--version", required=True)
validate = commands.add_parser("validate")
validate.add_argument("file", type=Path)
commands.add_parser("list")
show = commands.add_parser("show")
show.add_argument("revision")
diff = commands.add_parser("diff")
diff.add_argument("before", type=Path)
diff.add_argument("after", type=Path)
diff.add_argument("--format", choices=("text", "json", "markdown", "html"), default="text")
return root
async def run(arguments: argparse.Namespace) -> int:
store = RevisionStore(arguments.store)
if arguments.command == "list":
for revision in store.list():
print(revision)
return 0
if arguments.command == "show":
print(json.dumps(store.manifest(arguments.revision).model_dump(mode="json"), indent=2))
return 0
if arguments.command == "diff":
before = Snapshot.model_validate_json(arguments.before.read_bytes())
after = Snapshot.model_validate_json(arguments.after.read_bytes())
changes = compare_snapshots(before, after)
renderers = {
"text": render_text,
"json": render_json,
"markdown": render_markdown,
"html": render_html,
}
print(renderers[arguments.format](changes))
return 1 if has_breaking_changes(changes) else 0
if arguments.command == "validate":
parsed = ApiViewerParser().parse(arguments.file.read_bytes())
print(json.dumps({"nodes": len(parsed.nodes), "warnings": len(parsed.warnings)}))
return 0
importer: SourceImporter
if arguments.file is not None:
importer = LocalFileImporter(arguments.file)
else:
importer = RemoteSourceImporter(arguments.url)
raw = await importer.load()
parsed = ApiViewerParser().parse(raw)
snapshot, manifest = normalize_snapshot(
parsed, raw=raw, source_version=arguments.version, retrieved_at=datetime.now(UTC)
)
print(store.save(raw, snapshot, manifest))
return 0
def main() -> None:
raise SystemExit(asyncio.run(run(parser().parse_args())))
+224
View File
@@ -0,0 +1,224 @@
"""Deterministic semantic differences between normalized snapshots."""
from __future__ import annotations
import html
import json
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from app.contracts.model import Method, Parameter, PathContract, Snapshot
class Severity(StrEnum):
BREAKING = "breaking"
NON_BREAKING = "non-breaking"
DOCUMENTATION = "documentation"
@dataclass(frozen=True, slots=True, order=True)
class Change:
path: str
method: str
category: str
severity: Severity
detail: str
before: str | None = None
after: str | None = None
def _stable(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _methods(snapshot: Snapshot) -> dict[tuple[str, str], Method]:
return {(path.path, method.verb): method for path in snapshot.paths for method in path.methods}
def _paths(snapshot: Snapshot) -> dict[str, PathContract]:
return {path.path: path for path in snapshot.paths}
def compare_snapshots(before: Snapshot, after: Snapshot) -> tuple[Change, ...]:
changes: list[Change] = []
old_paths, new_paths = _paths(before), _paths(after)
for path in sorted(old_paths.keys() - new_paths.keys()):
changes.append(Change(path, "", "path", Severity.BREAKING, "path removed"))
for path in sorted(new_paths.keys() - old_paths.keys()):
changes.append(Change(path, "", "path", Severity.NON_BREAKING, "path added"))
old_methods, new_methods = _methods(before), _methods(after)
for path, verb in sorted(old_methods.keys() - new_methods.keys()):
changes.append(Change(path, verb, "method", Severity.BREAKING, "method removed"))
for path, verb in sorted(new_methods.keys() - old_methods.keys()):
changes.append(Change(path, verb, "method", Severity.NON_BREAKING, "method added"))
for key in sorted(old_methods.keys() & new_methods.keys()):
_compare_method(key, old_methods[key], new_methods[key], changes)
return tuple(sorted(changes))
def _compare_method(
key: tuple[str, str], before: Method, after: Method, changes: list[Change]
) -> None:
path, verb = key
if before.description != after.description:
changes.append(
Change(
path,
verb,
"documentation",
Severity.DOCUMENTATION,
"description changed",
before.description,
after.description,
)
)
if before.permissions != after.permissions:
changes.append(
Change(
path,
verb,
"permissions",
Severity.BREAKING,
"permissions changed",
_stable(before.permissions.model_dump(mode="json") if before.permissions else None),
_stable(after.permissions.model_dump(mode="json") if after.permissions else None),
)
)
_compare_parameters(path, verb, before.parameters, after.parameters, changes)
_compare_schema(
path,
verb,
"returns",
before.returns.model_dump(mode="json"),
after.returns.model_dump(mode="json"),
changes,
)
def _compare_parameters(
path: str,
verb: str,
before: tuple[Parameter, ...],
after: tuple[Parameter, ...],
changes: list[Change],
) -> None:
old = {parameter.name: parameter for parameter in before}
new = {parameter.name: parameter for parameter in after}
for name in sorted(old.keys() - new.keys()):
changes.append(Change(path, verb, "parameter", Severity.BREAKING, f"removed: {name}"))
for name in sorted(new.keys() - old.keys()):
severity = Severity.NON_BREAKING if new[name].definition.optional else Severity.BREAKING
changes.append(Change(path, verb, "parameter", severity, f"added: {name}"))
for name in sorted(old.keys() & new.keys()):
_compare_schema(
path,
verb,
f"parameter:{name}",
old[name].definition.model_dump(mode="json"),
new[name].definition.model_dump(mode="json"),
changes,
)
def _compare_schema(
path: str,
verb: str,
label: str,
before: dict[str, Any],
after: dict[str, Any],
changes: list[Change],
) -> None:
groups = {
"schema": {"type", "properties", "items", "enum", "format", "pattern"},
"default": {"default", "optional"},
"constraint": {"minimum", "maximum", "min_length", "max_length"},
"documentation": {"description"},
}
for category, fields in groups.items():
old = {field: before.get(field) for field in fields}
new = {field: after.get(field) for field in fields}
if old != new:
severity = Severity.DOCUMENTATION if category == "documentation" else Severity.BREAKING
changes.append(
Change(
path,
verb,
category,
severity,
f"{label} {category} changed",
_stable(old),
_stable(new),
)
)
def render_json(changes: tuple[Change, ...]) -> str:
return json.dumps(
[
{
"after": change.after,
"before": change.before,
"category": change.category,
"detail": change.detail,
"method": change.method,
"path": change.path,
"severity": change.severity,
}
for change in changes
],
ensure_ascii=False,
indent=2,
)
def render_text(changes: tuple[Change, ...]) -> str:
return "\n".join(
(
f"{change.severity}: {change.method} {change.path} [{change.category}] {change.detail}"
).strip()
for change in changes
)
def render_markdown(changes: tuple[Change, ...]) -> str:
lines = [
"# API contract diff",
"",
"| Severity | Method | Path | Category | Detail |",
"|---|---|---|---|---|",
]
lines.extend(
(
f"| {change.severity} | {change.method} | `{change.path}` | "
f"{change.category} | {change.detail} |"
)
for change in changes
)
return "\n".join(lines)
def render_html(changes: tuple[Change, ...]) -> str:
rows = "".join(
"<tr>"
+ "".join(
f"<td>{html.escape(str(value))}</td>"
for value in (
change.severity,
change.method,
change.path,
change.category,
change.detail,
)
)
+ "</tr>"
for change in changes
)
return (
f"<!doctype html><meta charset=utf-8><title>API contract diff</title><table>{rows}</table>"
)
def has_breaking_changes(changes: tuple[Change, ...]) -> bool:
return any(change.severity is Severity.BREAKING for change in changes)
+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
+110
View File
@@ -0,0 +1,110 @@
"""Network-constrained remote contract retrieval."""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from urllib.parse import urljoin, urlsplit
import httpx
from app.contracts.source import SourceError
Resolver = Callable[[str], Awaitable[tuple[str, ...]]]
async def resolve_host(host: str) -> tuple[str, ...]:
loop = asyncio.get_running_loop()
results = await loop.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
return tuple(sorted({str(result[4][0]) for result in results}))
def validate_remote_url(url: str, allowed_hosts: frozenset[str]) -> str:
parsed = urlsplit(url)
if parsed.scheme != "https":
raise SourceError("remote imports require HTTPS")
if parsed.username or parsed.password or parsed.port not in (None, 443):
raise SourceError("remote URL contains forbidden authority components")
host = (parsed.hostname or "").rstrip(".").lower()
if host not in allowed_hosts:
raise SourceError("remote host is not in the official-domain allowlist")
if parsed.fragment:
raise SourceError("remote URL fragments are not allowed")
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 _is_allowed_resolved_address(address):
raise SourceError(f"remote host resolved to a non-public address: {value}")
@dataclass(frozen=True, slots=True)
class RemoteSourceImporter:
url: str
allowed_hosts: frozenset[str] = frozenset({"pve.proxmox.com"})
max_bytes: int = 16 * 1024 * 1024
max_redirects: int = 3
retries: int = 2
timeout_seconds: float = 20.0
resolver: Resolver = resolve_host
transport: httpx.AsyncBaseTransport | None = None
async def load(self) -> bytes:
current = self.url
timeout = httpx.Timeout(self.timeout_seconds)
async with httpx.AsyncClient(
follow_redirects=False, timeout=timeout, transport=self.transport
) as client:
for redirect_count in range(self.max_redirects + 1):
host = validate_remote_url(current, self.allowed_hosts)
validate_public_addresses(await self.resolver(host))
response = await self._request(client, current)
if response.is_redirect:
if redirect_count == self.max_redirects:
raise SourceError("remote import exceeded redirect limit")
location = response.headers.get("location")
if not location:
raise SourceError("remote redirect has no location")
current = urljoin(current, location)
continue
response.raise_for_status()
content_length = response.headers.get("content-length")
if content_length and int(content_length) > self.max_bytes:
raise SourceError("remote artifact exceeds size limit")
content = response.content
if len(content) > self.max_bytes:
raise SourceError("remote artifact exceeds size limit")
return content
raise SourceError("remote import failed")
async def _request(self, client: httpx.AsyncClient, url: str) -> httpx.Response:
for attempt in range(self.retries + 1):
try:
return await client.get(url)
except (httpx.TimeoutException, httpx.NetworkError):
if attempt == self.retries:
raise
await asyncio.sleep(0.1 * (2**attempt))
raise SourceError("remote import retry loop exhausted")
+134
View File
@@ -0,0 +1,134 @@
"""Immutable normalized representation of Proxmox API contracts."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any, Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
def canonical_json(value: BaseModel | Mapping[str, Any] | Sequence[Any]) -> bytes:
"""Serialize a JSON-compatible value deterministically as UTF-8."""
data: Any
if isinstance(value, BaseModel):
data = value.model_dump(mode="json", exclude_none=True)
else:
data = value
return json.dumps(
data,
default=_json_default,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode()
def _json_default(value: object) -> Any:
if isinstance(value, BaseModel):
return value.model_dump(mode="json", exclude_none=True)
if isinstance(value, datetime):
return value.isoformat()
raise TypeError(f"{type(value).__name__} is not JSON serializable")
def sha256(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
class FrozenModel(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
class Schema(FrozenModel):
"""Proxmox's JSON-Schema-like dialect with retained extensions."""
type: str | None = None
description: str | None = None
properties: dict[str, Schema] = Field(default_factory=dict)
items: Schema | None = None
enum: tuple[JsonValue, ...] = ()
optional: bool | None = None
default: JsonValue = None
minimum: int | float | None = None
maximum: int | float | None = None
min_length: int | None = None
max_length: int | None = None
pattern: str | None = None
format: str | dict[str, JsonValue] | None = None
extra: dict[str, JsonValue] = Field(default_factory=dict)
class Parameter(FrozenModel):
name: str
definition: Schema
class Permissions(FrozenModel):
user: str | None = None
description: str | None = None
expression: dict[str, JsonValue] = Field(default_factory=dict)
extra: dict[str, JsonValue] = Field(default_factory=dict)
class Method(FrozenModel):
verb: str
name: str
description: str | None = None
parameters: tuple[Parameter, ...] = ()
returns: Schema = Field(default_factory=Schema)
permissions: Permissions | None = None
protected: bool = False
allow_token: bool | None = None
extra: dict[str, JsonValue] = Field(default_factory=dict)
checksum: str
class PathContract(FrozenModel):
path: str
methods: tuple[Method, ...]
extra: dict[str, JsonValue] = Field(default_factory=dict)
class Snapshot(FrozenModel):
format_version: int = 1
source_version: str
retrieved_at: datetime
raw_sha256: str
paths: tuple[PathContract, ...]
path_count: int
method_count: int
extra: dict[str, JsonValue] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_counts_and_uniqueness(self) -> Self:
if self.path_count != len(self.paths):
raise ValueError("path_count does not match paths")
methods = sum(len(path.methods) for path in self.paths)
if self.method_count != methods:
raise ValueError("method_count does not match methods")
keys = [(path.path, method.verb) for path in self.paths for method in path.methods]
if len(keys) != len(set(keys)):
raise ValueError("duplicate path and method")
return self
def canonical_bytes(self) -> bytes:
return canonical_json(self)
def checksum(self) -> str:
return sha256(self.canonical_bytes())
class Manifest(FrozenModel):
source_version: str
raw_sha256: str
snapshot_sha256: str
path_count: int
method_count: int
+168
View File
@@ -0,0 +1,168 @@
"""Normalize parsed API Viewer trees into stable contract models."""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
from typing import Any, cast
from app.contracts.model import (
JsonValue,
Manifest,
Method,
Parameter,
PathContract,
Permissions,
Schema,
Snapshot,
canonical_json,
sha256,
)
from app.contracts.source import ParsedSource
SCHEMA_FIELDS = {
"type",
"description",
"properties",
"items",
"enum",
"optional",
"default",
"minimum",
"maximum",
"minLength",
"maxLength",
"pattern",
"format",
}
METHOD_FIELDS = {
"allowtoken",
"description",
"method",
"name",
"parameters",
"permissions",
"protected",
"returns",
}
def _json(value: Any) -> JsonValue:
return cast(JsonValue, value)
def normalize_schema(raw: Mapping[str, Any] | None) -> Schema:
source = raw or {}
properties = source.get("properties") or {}
normalized_properties = {
str(name): normalize_schema(cast(Mapping[str, Any], schema))
for name, schema in cast(Mapping[str, Any], properties).items()
}
items = source.get("items")
extra = {key: _json(value) for key, value in source.items() if key not in SCHEMA_FIELDS}
return Schema(
type=source.get("type"),
description=source.get("description"),
properties=normalized_properties,
items=normalize_schema(cast(Mapping[str, Any], items))
if isinstance(items, Mapping)
else None,
enum=tuple(_json(value) for value in (source.get("enum") or ())),
optional=bool(source["optional"]) if "optional" in source else None,
default=_json(source.get("default")),
minimum=source.get("minimum"),
maximum=source.get("maximum"),
min_length=source.get("minLength"),
max_length=source.get("maxLength"),
pattern=source.get("pattern"),
format=_json(source.get("format")),
extra=extra,
)
def normalize_permissions(raw: Mapping[str, Any] | None) -> Permissions | None:
if raw is None:
return None
known = {"user", "description"}
expression_keys = {"and", "or", "check", "userParam"}
return Permissions(
user=raw.get("user"),
description=raw.get("description"),
expression={key: _json(raw[key]) for key in expression_keys if key in raw},
extra={
key: _json(value) for key, value in raw.items() if key not in known | expression_keys
},
)
def normalize_method(verb: str, raw: Mapping[str, Any]) -> Method:
parameters_raw = cast(Mapping[str, Any], raw.get("parameters") or {}).get("properties") or {}
parameters = tuple(
Parameter(name=str(name), definition=normalize_schema(cast(Mapping[str, Any], schema)))
for name, schema in sorted(cast(Mapping[str, Any], parameters_raw).items())
)
values: dict[str, Any] = {
"verb": verb.upper(),
"name": str(raw.get("name", verb.lower())),
"description": raw.get("description"),
"parameters": parameters,
"returns": normalize_schema(cast(Mapping[str, Any] | None, raw.get("returns"))),
"permissions": normalize_permissions(
cast(Mapping[str, Any] | None, raw.get("permissions"))
),
"protected": bool(raw.get("protected", False)),
"allow_token": bool(raw["allowtoken"]) if "allowtoken" in raw else None,
"extra": {key: _json(value) for key, value in raw.items() if key not in METHOD_FIELDS},
}
checksum = sha256(canonical_json(values))
return Method(**values, checksum=checksum)
def _walk(nodes: tuple[dict[str, Any], ...]) -> list[PathContract]:
paths: list[PathContract] = []
def visit(node: Mapping[str, Any]) -> None:
info = node.get("info")
path = node.get("path")
if isinstance(info, Mapping) and isinstance(path, str):
methods = tuple(
normalize_method(str(verb), cast(Mapping[str, Any], method))
for verb, method in sorted(info.items())
if isinstance(method, Mapping)
)
extra = {
key: _json(value)
for key, value in node.items()
if key not in {"children", "info", "leaf", "path", "text"}
}
paths.append(PathContract(path=path, methods=methods, extra=extra))
for child in node.get("children", ()):
if isinstance(child, Mapping):
visit(child)
for root in nodes:
visit(root)
return sorted(paths, key=lambda item: item.path)
def normalize_snapshot(
parsed: ParsedSource, *, raw: bytes, source_version: str, retrieved_at: datetime
) -> tuple[Snapshot, Manifest]:
paths = tuple(_walk(parsed.nodes))
snapshot = Snapshot(
source_version=source_version,
retrieved_at=retrieved_at,
raw_sha256=sha256(raw),
paths=paths,
path_count=len(paths),
method_count=sum(len(path.methods) for path in paths),
extra={"warning_count": len(parsed.warnings)},
)
manifest = Manifest(
source_version=source_version,
raw_sha256=snapshot.raw_sha256,
snapshot_sha256=snapshot.checksum(),
path_count=snapshot.path_count,
method_count=snapshot.method_count,
)
return snapshot, manifest
+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")
+154
View File
@@ -0,0 +1,154 @@
"""Safe source adapters for Proxmox API Viewer artifacts."""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol, cast
class SourceError(ValueError):
"""Raised when an API source cannot be parsed safely."""
@dataclass(frozen=True, slots=True)
class ParseWarning:
"""A recoverable variation found in a source artifact."""
code: str
path: str
message: str
@dataclass(frozen=True, slots=True)
class ParsedSource:
"""Parsed source tree with non-fatal diagnostics."""
nodes: tuple[dict[str, Any], ...]
warnings: tuple[ParseWarning, ...] = ()
class SourceImporter(Protocol):
"""Asynchronous boundary for obtaining source artifact bytes."""
async def load(self) -> bytes: ...
@dataclass(frozen=True, slots=True)
class LocalFileImporter:
"""Load an artifact from a caller-selected local path."""
path: Path
async def load(self) -> bytes:
return self.path.read_bytes()
class ApiViewerParser:
"""Extract the JSON-compatible schema value without executing JS."""
declarations = (b"const apiSchema", b"var pveapi")
known_node_fields = frozenset({"children", "info", "leaf", "path", "text"})
def parse(self, raw: bytes) -> ParsedSource:
if not raw.strip():
raise SourceError("source artifact is empty")
payload = self._extract_payload(raw)
try:
decoded = json.loads(payload)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SourceError(f"invalid apiSchema JSON: {exc}") from exc
if isinstance(decoded, Mapping):
raw_nodes = [decoded]
elif isinstance(decoded, list):
raw_nodes = decoded
else:
raise SourceError("apiSchema must be an object or array of objects")
nodes: list[dict[str, Any]] = []
warnings: list[ParseWarning] = []
for index, value in enumerate(raw_nodes):
if not isinstance(value, Mapping):
raise SourceError(f"apiSchema node /{index} must be an object")
node = cast(dict[str, Any], dict(value))
nodes.append(node)
self._inspect_node(node, f"/{index}", warnings)
return ParsedSource(tuple(nodes), tuple(warnings))
def _extract_payload(self, raw: bytes) -> bytes:
stripped = raw.strip()
if stripped.startswith((b"[", b"{")):
return stripped
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]
raise SourceError("apiSchema declaration was not found")
@staticmethod
def _next_non_space(raw: bytes, start: int) -> int:
while start < len(raw) and raw[start] in b" \t\r\n":
start += 1
return start
@staticmethod
def _matching_end(raw: bytes, start: int) -> int:
opening = raw[start]
closing = ord("]") if opening == ord("[") else ord("}")
depth = 0
in_string = False
escaped = False
for index in range(start, len(raw)):
byte = raw[index]
if in_string:
if escaped:
escaped = False
elif byte == ord("\\"):
escaped = True
elif byte == ord('"'):
in_string = False
continue
if byte == ord('"'):
in_string = True
elif byte == opening:
depth += 1
elif byte == closing:
depth -= 1
if depth == 0:
return index
raise SourceError("apiSchema assignment is truncated")
def _inspect_node(
self, node: Mapping[str, Any], path: str, warnings: list[ParseWarning]
) -> None:
for field in sorted(node.keys() - self.known_node_fields):
warnings.append(
ParseWarning("unknown-node-field", f"{path}/{field}", "field was preserved")
)
children = node.get("children", [])
if not isinstance(children, list):
warnings.append(
ParseWarning("invalid-children", f"{path}/children", "expected an array")
)
return
for index, child in enumerate(children):
child_path = f"{path}/children/{index}"
if isinstance(child, Mapping):
self._inspect_node(child, child_path, warnings)
else:
warnings.append(ParseWarning("invalid-child", child_path, "expected an object"))
+48
View File
@@ -0,0 +1,48 @@
"""Immutable filesystem storage for imported contract revisions."""
from __future__ import annotations
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from app.contracts.model import Manifest, Snapshot, canonical_json
@dataclass(frozen=True, slots=True)
class RevisionStore:
root: Path
def save(self, raw: bytes, snapshot: Snapshot, manifest: Manifest) -> Path:
revision = self.root / manifest.snapshot_sha256
if revision.exists():
return revision
self.root.mkdir(parents=True, exist_ok=True)
temporary = Path(tempfile.mkdtemp(prefix=".import-", dir=self.root))
try:
self._write(temporary / "raw.js", raw)
self._write(temporary / "snapshot.json", snapshot.canonical_bytes())
self._write(temporary / "manifest.json", canonical_json(manifest))
os.replace(temporary, revision)
except BaseException:
for child in temporary.iterdir():
child.unlink()
temporary.rmdir()
raise
return revision
def list(self) -> tuple[str, ...]:
if not self.root.exists():
return ()
return tuple(sorted(path.name for path in self.root.iterdir() if path.is_dir()))
def manifest(self, revision: str) -> Manifest:
return Manifest.model_validate_json((self.root / revision / "manifest.json").read_bytes())
@staticmethod
def _write(path: Path, content: bytes) -> None:
with path.open("xb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
+1
View File
@@ -0,0 +1 @@
"""PostgreSQL infrastructure."""
+16
View File
@@ -0,0 +1,16 @@
"""Apply configured database migrations."""
import asyncio
from app.config import get_settings
from app.db.migrations import migrate_url
async def run() -> None:
settings = get_settings()
count = await migrate_url(settings.database_url.get_secret_value())
print(f"applied {count} migration(s)")
if __name__ == "__main__":
asyncio.run(run())
+70
View File
@@ -0,0 +1,70 @@
"""Checksummed asynchronous PostgreSQL migration runner."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
import asyncpg # type: ignore[import-untyped]
from asyncpg import Connection
class MigrationError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class Migration:
version: int
name: str
sql: str
checksum: str
def load_migrations(root: Path | None = None) -> tuple[Migration, ...]:
directory = root or Path(__file__).with_name("migrations")
migrations = []
for path in sorted(directory.glob("[0-9][0-9][0-9]_*.sql")):
version = int(path.name.split("_", 1)[0])
sql = path.read_text()
migrations.append(
Migration(version, path.stem, sql, hashlib.sha256(sql.encode()).hexdigest())
)
return tuple(migrations)
async def migrate(connection: Connection, migrations: tuple[Migration, ...] | None = None) -> int:
await connection.execute(
"""CREATE TABLE IF NOT EXISTS schema_migrations (
version integer PRIMARY KEY, name text NOT NULL, checksum text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now())"""
)
applied = {
int(row["version"]): str(row["checksum"])
for row in await connection.fetch("SELECT version, checksum FROM schema_migrations")
}
count = 0
for migration in migrations or load_migrations():
if migration.version in applied:
if applied[migration.version] != migration.checksum:
raise MigrationError(f"migration {migration.version} checksum mismatch")
continue
async with connection.transaction():
await connection.execute(migration.sql)
await connection.execute(
"INSERT INTO schema_migrations(version, name, checksum) VALUES($1, $2, $3)",
migration.version,
migration.name,
migration.checksum,
)
count += 1
return count
async def migrate_url(database_url: str) -> int:
connection = await asyncpg.connect(database_url)
try:
return await migrate(connection)
finally:
await connection.close()
+60
View File
@@ -0,0 +1,60 @@
CREATE TABLE contract_snapshots (
checksum text PRIMARY KEY,
source_version text NOT NULL,
document jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE nodes (
id uuid PRIMARY KEY,
name text NOT NULL UNIQUE,
status text NOT NULL CHECK (status IN ('online', 'offline'))
);
CREATE TABLE resources (
id uuid PRIMARY KEY,
node_id uuid NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT,
kind text NOT NULL,
external_id text NOT NULL,
state jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (kind, external_id)
);
CREATE INDEX resources_node_id_idx ON resources(node_id);
CREATE TABLE principals (
id uuid PRIMARY KEY,
name text NOT NULL UNIQUE,
password_hash text
);
CREATE TABLE roles (
name text PRIMARY KEY,
privileges text[] NOT NULL DEFAULT '{}'
);
CREATE TABLE acl_entries (
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
role_name text NOT NULL REFERENCES roles(name) ON DELETE RESTRICT,
path text NOT NULL,
propagate boolean NOT NULL DEFAULT true,
PRIMARY KEY (principal_id, role_name, path)
);
CREATE TABLE tasks (
id uuid PRIMARY KEY,
upid text NOT NULL UNIQUE,
status text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX tasks_status_created_idx ON tasks(status, created_at);
CREATE TABLE scenarios (
id uuid PRIMARY KEY,
name text NOT NULL UNIQUE,
definition jsonb NOT NULL,
enabled boolean NOT NULL DEFAULT true
);
CREATE TABLE audit_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
principal text,
action text NOT NULL,
target text,
details jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX audit_events_occurred_idx ON audit_events(occurred_at);
@@ -0,0 +1,16 @@
CREATE TABLE realms (
name text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('pam', 'pve', 'openid', 'ldap'))
);
INSERT INTO realms(name, kind) VALUES ('pam', 'pam'), ('pve', 'pve');
ALTER TABLE principals ADD COLUMN realm_name text REFERENCES realms(name) ON DELETE RESTRICT;
CREATE TABLE api_tokens (
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
token_id text NOT NULL,
secret_hash text NOT NULL,
privileges text[] NOT NULL DEFAULT '{}',
expires_at timestamptz,
PRIMARY KEY (principal_id, token_id),
CHECK (secret_hash LIKE 'scrypt$%')
);
CREATE INDEX api_tokens_expires_idx ON api_tokens(expires_at) WHERE expires_at IS NOT NULL;
+32
View File
@@ -0,0 +1,32 @@
ALTER TABLE tasks
ADD COLUMN task_type text NOT NULL DEFAULT 'generic',
ADD COLUMN progress integer NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
ADD COLUMN result jsonb,
ADD COLUMN error text,
ADD COLUMN worker_id text,
ADD COLUMN lease_expires_at timestamptz,
ADD COLUMN cancel_requested boolean NOT NULL DEFAULT false,
ADD COLUMN idempotency_key text UNIQUE,
ADD COLUMN attempt integer NOT NULL DEFAULT 0,
ADD CONSTRAINT tasks_status_check CHECK (status IN ('queued', 'running', 'success', 'error', 'cancelled'));
CREATE INDEX tasks_claim_idx ON tasks(status, lease_expires_at, created_at);
CREATE TABLE resource_locks (
resource_key text PRIMARY KEY,
task_id uuid NOT NULL UNIQUE REFERENCES tasks(id) ON DELETE CASCADE,
acquired_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE task_logs (
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
sequence bigint GENERATED ALWAYS AS IDENTITY,
created_at timestamptz NOT NULL DEFAULT now(),
message text NOT NULL,
PRIMARY KEY (task_id, sequence)
);
CREATE TABLE task_events (
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
sequence bigint GENERATED ALWAYS AS IDENTITY,
created_at timestamptz NOT NULL DEFAULT now(),
kind text NOT NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (task_id, sequence)
);
+204
View File
@@ -0,0 +1,204 @@
CREATE TABLE clusters (
id uuid PRIMARY KEY,
external_id text NOT NULL UNIQUE,
name text NOT NULL,
version integer NOT NULL DEFAULT 1,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO clusters(id, external_id, name)
VALUES ('dc760c47-d8d7-57e6-9404-f0c6f2395d8f', 'default', 'pve-simulator');
ALTER TABLE nodes
ADD COLUMN cluster_id uuid NOT NULL DEFAULT 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f'
REFERENCES clusters(id) ON DELETE CASCADE,
ADD COLUMN version integer NOT NULL DEFAULT 1,
ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE resources
ADD COLUMN cluster_id uuid NOT NULL DEFAULT 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f'
REFERENCES clusters(id) ON DELETE CASCADE,
ADD COLUMN version integer NOT NULL DEFAULT 1,
ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
CREATE UNIQUE INDEX resources_cluster_vmid_idx
ON resources(cluster_id, external_id) WHERE kind IN ('qemu', 'lxc');
CREATE TABLE virtual_machines (
resource_id uuid PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE,
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
vmid integer NOT NULL CHECK (vmid BETWEEN 100 AND 999999999),
config jsonb NOT NULL DEFAULT '{}'::jsonb,
lock text,
template boolean NOT NULL DEFAULT false,
UNIQUE (cluster_id, vmid)
);
CREATE TABLE containers (
resource_id uuid PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE,
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
vmid integer NOT NULL CHECK (vmid BETWEEN 100 AND 999999999),
config jsonb NOT NULL DEFAULT '{}'::jsonb,
lock text,
template boolean NOT NULL DEFAULT false,
UNIQUE (cluster_id, vmid)
);
CREATE TABLE vm_disks (
id uuid PRIMARY KEY,
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
device text NOT NULL,
storage_id text NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (resource_id, device)
);
CREATE TABLE vm_network_interfaces (
id uuid PRIMARY KEY,
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
device text NOT NULL,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (resource_id, device)
);
CREATE TABLE storages (
resource_id uuid PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE,
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
storage_id text NOT NULL,
storage_type text NOT NULL,
shared boolean NOT NULL DEFAULT false,
capacity_bytes bigint CHECK (capacity_bytes >= 0),
used_bytes bigint CHECK (used_bytes >= 0),
config jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (cluster_id, storage_id)
);
CREATE TABLE storage_contents (
id uuid PRIMARY KEY,
storage_resource_id uuid NOT NULL REFERENCES storages(resource_id) ON DELETE CASCADE,
volume_id text NOT NULL,
content_type text NOT NULL,
size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (storage_resource_id, volume_id)
);
CREATE TABLE snapshots (
id uuid PRIMARY KEY,
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
name text NOT NULL,
parent_name text,
description text,
state jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (resource_id, name)
);
CREATE TABLE backups (
id uuid PRIMARY KEY,
resource_id uuid REFERENCES resources(id) ON DELETE SET NULL,
storage_resource_id uuid NOT NULL REFERENCES storages(resource_id) ON DELETE CASCADE,
volume_id text NOT NULL,
size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (storage_resource_id, volume_id)
);
CREATE TABLE pools (
id uuid PRIMARY KEY,
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
pool_id text NOT NULL,
comment text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (cluster_id, pool_id)
);
CREATE TABLE pool_members (
pool_id uuid NOT NULL REFERENCES pools(id) ON DELETE CASCADE,
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
PRIMARY KEY (pool_id, resource_id)
);
CREATE TABLE identity_groups (
id uuid PRIMARY KEY,
group_id text NOT NULL UNIQUE,
comment text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE identity_group_members (
group_id uuid NOT NULL REFERENCES identity_groups(id) ON DELETE CASCADE,
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, principal_id)
);
CREATE TABLE auth_tickets (
id uuid PRIMARY KEY,
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
ticket_hash text NOT NULL UNIQUE,
issued_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
CREATE INDEX auth_tickets_expiry_idx ON auth_tickets(expires_at) WHERE revoked_at IS NULL;
CREATE TABLE contract_paths (
snapshot_checksum text NOT NULL REFERENCES contract_snapshots(checksum) ON DELETE CASCADE,
path text NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (snapshot_checksum, path)
);
CREATE TABLE contract_methods (
snapshot_checksum text NOT NULL,
path text NOT NULL,
verb text NOT NULL,
fingerprint text NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (snapshot_checksum, path, verb),
FOREIGN KEY (snapshot_checksum, path)
REFERENCES contract_paths(snapshot_checksum, path) ON DELETE CASCADE
);
CREATE TABLE contract_parameters (
snapshot_checksum text NOT NULL,
path text NOT NULL,
verb text NOT NULL,
name text NOT NULL,
location text NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (snapshot_checksum, path, verb, name, location),
FOREIGN KEY (snapshot_checksum, path, verb)
REFERENCES contract_methods(snapshot_checksum, path, verb) ON DELETE CASCADE
);
CREATE TABLE contract_schema_fragments (
id uuid PRIMARY KEY,
snapshot_checksum text NOT NULL REFERENCES contract_snapshots(checksum) ON DELETE CASCADE,
fingerprint text NOT NULL,
document jsonb NOT NULL,
UNIQUE (snapshot_checksum, fingerprint)
);
CREATE TABLE observed_contracts (
id uuid PRIMARY KEY,
source_version text NOT NULL,
method_fingerprint text NOT NULL,
observation jsonb NOT NULL,
observed_at timestamptz NOT NULL,
UNIQUE (source_version, method_fingerprint, observed_at)
);
CREATE TABLE scenario_rules (
id uuid PRIMARY KEY,
scenario_id uuid NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
priority integer NOT NULL DEFAULT 0,
matcher jsonb NOT NULL,
action jsonb NOT NULL,
enabled boolean NOT NULL DEFAULT true
);
CREATE INDEX scenario_rules_scenario_priority_idx ON scenario_rules(scenario_id, priority DESC);
CREATE TABLE fault_injections (
id uuid PRIMARY KEY,
scenario_id uuid REFERENCES scenarios(id) ON DELETE CASCADE,
fault_type text NOT NULL,
matcher jsonb NOT NULL,
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
active_from timestamptz,
active_until timestamptz,
enabled boolean NOT NULL DEFAULT true
);
@@ -0,0 +1,6 @@
INSERT INTO realms(name, kind) VALUES ('test', 'pve') ON CONFLICT (name) DO NOTHING;
ALTER TABLE api_tokens
ADD COLUMN comment text,
ADD COLUMN privilege_separation boolean NOT NULL DEFAULT true,
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE group_acl_entries (
group_id uuid NOT NULL REFERENCES identity_groups(id) ON DELETE CASCADE,
role_name text NOT NULL REFERENCES roles(name) ON DELETE RESTRICT,
path text NOT NULL,
propagate boolean NOT NULL DEFAULT true,
PRIMARY KEY (group_id, role_name, path)
);
CREATE INDEX identity_group_members_principal_idx
ON identity_group_members(principal_id, group_id);
+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()
);
+30
View File
@@ -0,0 +1,30 @@
-- Native vSphere inventory + sessions (independent of Proxmox resources).
CREATE TABLE vsphere_sessions (
id text PRIMARY KEY,
username text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL
);
CREATE INDEX vsphere_sessions_expires_idx ON vsphere_sessions (expires_at);
CREATE TABLE vsphere_objects (
moid text PRIMARY KEY,
type text NOT NULL,
name text NOT NULL,
parent_moid text REFERENCES vsphere_objects (moid) ON DELETE SET NULL,
props jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX vsphere_objects_type_idx ON vsphere_objects (type);
CREATE INDEX vsphere_objects_parent_idx ON vsphere_objects (parent_moid);
CREATE INDEX vsphere_objects_name_idx ON vsphere_objects (name);
CREATE TABLE vsphere_credentials (
username text PRIMARY KEY,
password_hash text NOT NULL,
roles text[] NOT NULL DEFAULT '{Administrator}'
);
@@ -0,0 +1,87 @@
-- Tasks, snapshots, tagging, content library, datastore files, roles.
CREATE TABLE vsphere_tasks (
id text PRIMARY KEY,
description text NOT NULL DEFAULT '',
status text NOT NULL CHECK (status IN ('PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED')),
service text NOT NULL DEFAULT '',
operation text NOT NULL DEFAULT '',
result jsonb,
error jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE INDEX vsphere_tasks_status_idx ON vsphere_tasks (status);
CREATE TABLE vsphere_snapshots (
id text PRIMARY KEY,
vm_moid text NOT NULL REFERENCES vsphere_objects (moid) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
props jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX vsphere_snapshots_vm_idx ON vsphere_snapshots (vm_moid);
CREATE TABLE vsphere_tag_categories (
id text PRIMARY KEY,
name text NOT NULL UNIQUE,
description text NOT NULL DEFAULT '',
cardinality text NOT NULL DEFAULT 'MULTIPLE',
associable_types text[] NOT NULL DEFAULT '{}'
);
CREATE TABLE vsphere_tags (
id text PRIMARY KEY,
category_id text NOT NULL REFERENCES vsphere_tag_categories (id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
UNIQUE (category_id, name)
);
CREATE TABLE vsphere_tag_associations (
tag_id text NOT NULL REFERENCES vsphere_tags (id) ON DELETE CASCADE,
object_type text NOT NULL,
object_id text NOT NULL,
PRIMARY KEY (tag_id, object_type, object_id)
);
CREATE TABLE vsphere_libraries (
id text PRIMARY KEY,
name text NOT NULL UNIQUE,
description text NOT NULL DEFAULT '',
type text NOT NULL DEFAULT 'LOCAL',
props jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE vsphere_library_items (
id text PRIMARY KEY,
library_id text NOT NULL REFERENCES vsphere_libraries (id) ON DELETE CASCADE,
name text NOT NULL,
type text NOT NULL DEFAULT 'ovf',
description text NOT NULL DEFAULT '',
props jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (library_id, name)
);
CREATE TABLE vsphere_datastore_files (
id bigserial PRIMARY KEY,
datastore_moid text NOT NULL REFERENCES vsphere_objects (moid) ON DELETE CASCADE,
path text NOT NULL,
size bigint NOT NULL DEFAULT 0,
type text NOT NULL DEFAULT 'FILE',
UNIQUE (datastore_moid, path)
);
CREATE TABLE vsphere_permissions (
id bigserial PRIMARY KEY,
principal text NOT NULL,
role text NOT NULL,
entity_moid text,
propagate boolean NOT NULL DEFAULT true
);
CREATE INDEX vsphere_permissions_principal_idx ON vsphere_permissions (principal);
@@ -0,0 +1,12 @@
-- Keyed JSON state for Broadcom Automation API surface (DB-backed stubs).
CREATE TABLE vsphere_api_state (
state_key text PRIMARY KEY,
verb text NOT NULL,
path_template text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX vsphere_api_state_path_idx ON vsphere_api_state (path_template);
CREATE INDEX vsphere_api_state_verb_idx ON vsphere_api_state (verb);
@@ -0,0 +1,30 @@
-- Durable content-library transfer sessions and NFC leases (no process memory).
CREATE TABLE vsphere_transfer_sessions (
id text NOT NULL,
kind text NOT NULL CHECK (kind IN ('download', 'update')),
library_item_id text NOT NULL REFERENCES vsphere_library_items (id) ON DELETE CASCADE,
state text NOT NULL DEFAULT 'ACTIVE',
files jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, kind)
);
CREATE INDEX vsphere_transfer_sessions_item_idx ON vsphere_transfer_sessions (library_item_id);
CREATE INDEX vsphere_transfer_sessions_kind_idx ON vsphere_transfer_sessions (kind);
CREATE TABLE vsphere_nfc_leases (
id text PRIMARY KEY,
vm_moid text NOT NULL,
state text NOT NULL DEFAULT 'ready',
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX vsphere_nfc_leases_vm_idx ON vsphere_nfc_leases (vm_moid);
-- Keep original seed document so DELETE can restore without Python templates.
ALTER TABLE vsphere_api_state
ADD COLUMN IF NOT EXISTS seed_payload jsonb;
@@ -0,0 +1,19 @@
-- Durable SOAP PropertyCollector views / page tokens / WaitForUpdates versions.
CREATE TABLE vsphere_pc_state (
kind text NOT NULL CHECK (kind IN ('view', 'token', 'version', 'meta')),
key text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (kind, key)
);
CREATE INDEX vsphere_pc_state_kind_idx ON vsphere_pc_state (kind);
-- Ephemeral console tickets issued by REST/SOAP.
CREATE TABLE vsphere_console_tickets (
ticket text PRIMARY KEY,
vm_moid text NOT NULL REFERENCES vsphere_objects (moid) ON DELETE CASCADE,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
+88
View File
@@ -0,0 +1,88 @@
"""Small typed asyncpg pool boundary."""
from __future__ import annotations
from typing import Protocol, Self, cast
import asyncpg # type: ignore[import-untyped]
from asyncpg import Pool
from app.config import Settings
from app.db.migrations import load_migrations
LATEST_SCHEMA_VERSION = max(migration.version for migration in load_migrations())
class Database(Protocol):
"""Application-facing database lifecycle and health interface."""
async def connect(self) -> None: ...
async def close(self) -> None: ...
async def is_ready(self) -> bool: ...
class AsyncpgDatabase:
"""Own an asyncpg pool without exposing it as global mutable state."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._pool: Pool | None = None
@property
def pool(self) -> Pool:
"""Return the initialized pool to repository factories."""
if self._pool is None:
message = "database pool is not initialized"
raise RuntimeError(message)
return self._pool
async def connect(self) -> None:
"""Create the pool and verify the first connection."""
if self._pool is not None:
return
settings = self._settings
pool = await asyncpg.create_pool(
dsn=settings.database_url.get_secret_value(),
min_size=settings.db_pool_min_size,
max_size=settings.db_pool_max_size,
timeout=settings.db_connect_timeout_seconds,
command_timeout=settings.db_command_timeout_seconds,
)
if pool is None: # pragma: no cover - asyncpg types allow this for legacy reasons
message = "asyncpg did not create a pool"
raise RuntimeError(message)
self._pool = cast(Pool, pool)
async def close(self) -> None:
"""Close all pooled connections; repeated close is safe."""
pool, self._pool = self._pool, None
if pool is not None:
await pool.close()
async def is_ready(self) -> bool:
"""Check connectivity and that all packaged migrations are applied."""
if self._pool is None:
return False
try:
return bool(
await self._pool.fetchval(
"""SELECT COALESCE(max(version), 0) >= $1
FROM schema_migrations""",
LATEST_SCHEMA_VERSION,
)
)
except asyncpg.PostgresError:
return False
async def __aenter__(self) -> Self:
await self.connect()
return self
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
await self.close()
+94
View File
@@ -0,0 +1,94 @@
"""Typed transactional helpers and stable database error mapping."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
import asyncpg # type: ignore[import-untyped]
from asyncpg import Connection, Pool
class DatabaseOperationError(RuntimeError):
"""Safe base error for repository operations."""
class ConflictError(DatabaseOperationError):
pass
class ReferenceError(DatabaseOperationError):
pass
class TransientDatabaseError(DatabaseOperationError):
pass
def map_database_error(error: asyncpg.PostgresError) -> DatabaseOperationError:
if isinstance(error, asyncpg.UniqueViolationError):
return ConflictError("database uniqueness constraint failed")
if isinstance(error, asyncpg.ForeignKeyViolationError):
return ReferenceError("database reference constraint failed")
if isinstance(
error,
asyncpg.SerializationError
| asyncpg.DeadlockDetectedError
| asyncpg.TooManyConnectionsError,
):
return TransientDatabaseError("transient database failure")
return DatabaseOperationError("database operation failed")
@asynccontextmanager
async def transaction(pool: Pool) -> AsyncIterator[Connection]:
async with pool.acquire() as connection:
try:
async with connection.transaction():
yield connection
except asyncpg.PostgresError as error:
raise map_database_error(error) from error
@asynccontextmanager
async def savepoint(connection: Connection) -> AsyncIterator[Connection]:
try:
async with connection.transaction():
yield connection
except asyncpg.PostgresError as error:
raise map_database_error(error) from error
def require_affected(status: str, expected: int = 1) -> None:
try:
affected = int(status.rsplit(" ", 1)[1])
except (IndexError, ValueError) as error:
raise DatabaseOperationError(f"unrecognized command status: {status}") from error
if affected != expected:
raise DatabaseOperationError(f"expected {expected} affected row(s), got {affected}")
@dataclass(frozen=True, slots=True)
class RetryPolicy:
attempts: int = 3
base_delay_seconds: float = 0.02
DEFAULT_RETRY_POLICY = RetryPolicy()
async def retry_transient[T](
operation: Callable[[], Awaitable[T]], policy: RetryPolicy = DEFAULT_RETRY_POLICY
) -> T:
if policy.attempts < 1:
raise ValueError("retry attempts must be positive")
for attempt in range(policy.attempts):
try:
return await operation()
except TransientDatabaseError:
if attempt + 1 == policy.attempts:
raise
await asyncio.sleep(policy.base_delay_seconds * (2**attempt))
raise RuntimeError("unreachable retry state")
+1
View File
@@ -0,0 +1 @@
"""Typed PostgreSQL repositories for simulation domain state."""
+97
View File
@@ -0,0 +1,97 @@
"""Typed resource persistence with explicit optimistic locking."""
from __future__ import annotations
import json
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, cast
from asyncpg import Pool # type: ignore[import-untyped]
from app.db.primitives import ConflictError, transaction
@dataclass(frozen=True, slots=True)
class ResourceRecord:
id: uuid.UUID
cluster_id: uuid.UUID
node: str
kind: str
external_id: str
state: dict[str, Any]
metadata: dict[str, Any]
version: int
def _json_object(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 _record(row: Mapping[str, object]) -> ResourceRecord:
return ResourceRecord(
id=cast(uuid.UUID, row["id"]),
cluster_id=cast(uuid.UUID, row["cluster_id"]),
node=str(row["node"]),
kind=str(row["kind"]),
external_id=str(row["external_id"]),
state=_json_object(row["state"]),
metadata=_json_object(row["metadata"]),
version=int(cast(int, row["version"])),
)
class ResourceRepository:
def __init__(self, pool: Pool) -> None:
self._pool = pool
async def list(
self, *, kind: str | None = None, node: str | None = None
) -> list[ResourceRecord]:
rows = await self._pool.fetch(
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
r.state, r.metadata, r.version
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE ($1::text IS NULL OR r.kind=$1)
AND ($2::text IS NULL OR n.name=$2)
ORDER BY r.kind, r.external_id""",
kind,
node,
)
return [_record(row) for row in rows]
async def get(self, *, kind: str, external_id: str) -> ResourceRecord | None:
row = await self._pool.fetchrow(
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
r.state, r.metadata, r.version
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind=$1 AND r.external_id=$2""",
kind,
external_id,
)
return None if row is None else _record(row)
async def update_state(
self,
resource_id: uuid.UUID,
*,
expected_version: int,
state: Mapping[str, object],
) -> ResourceRecord:
async with transaction(self._pool) as connection:
row = await connection.fetchrow(
"""UPDATE resources SET state=$3::jsonb, version=version+1,
updated_at=now() WHERE id=$1 AND version=$2
RETURNING id, cluster_id,
(SELECT name FROM nodes WHERE id=resources.node_id) AS node,
kind, external_id, state, metadata, version""",
resource_id,
expected_version,
json.dumps(dict(state), sort_keys=True),
)
if row is None:
raise ConflictError("resource version conflict or resource missing")
return _record(row)
+14
View File
@@ -0,0 +1,14 @@
"""FastAPI dependency adapters."""
from __future__ import annotations
from fastapi import Request
from app.db.pool import Database
def get_database(request: Request) -> Database:
"""Resolve the lifespan-owned database from application state."""
database: Database = request.app.state.database
return database
+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())
+1
View File
@@ -0,0 +1 @@
"""Semantic handlers for implemented Proxmox methods."""
+747
View File
@@ -0,0 +1,747 @@
"""Persistent Proxmox API-token lifecycle handlers."""
from __future__ import annotations
import json
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.access_auth import register_access_auth_handlers
from app.handlers.common import database, state, subdirs, values
from app.security.auth import hash_secret
_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:
principal = str(request.state.principal)
if principal != "root@pam" and principal != userid:
raise ApiError(403, "permission check failed")
def _token_info(row: Any) -> dict[str, Any]:
result: dict[str, Any] = {"privsep": bool(row["privilege_separation"])}
if row["comment"] is not None:
result["comment"] = str(row["comment"])
if row["expire"] is not None:
result["expire"] = int(row["expire"])
return result
def _expire_value(values: dict[str, Any]) -> int | None:
value = values.get("expire")
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"])
_require_owner(request, userid)
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
WHERE p.name=$1 ORDER BY t.token_id""",
userid,
)
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]:
payload = values(inputs)
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
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
WHERE p.name=$1 AND t.token_id=$2""",
userid,
tokenid,
)
if row is None:
raise ApiError(404, "API token does not exist")
return _token_info(row)
async def token_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
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(
"""INSERT INTO api_tokens(
principal_id, token_id, secret_hash, comment, expires_at,
privilege_separation
) SELECT id, $2, $3, $4,
CASE WHEN $5::bigint IS NULL OR $5=0 THEN NULL ELSE to_timestamp($5) END,
$6 FROM principals WHERE name=$1
ON CONFLICT (principal_id, token_id) DO NOTHING
RETURNING comment, privilege_separation,
extract(epoch from expires_at)::bigint AS expire""",
userid,
tokenid,
hash_secret(secret),
payload.get("comment"),
_expire_value(payload),
bool(payload.get("privsep", True)),
)
if row is None:
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]:
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(payload.get("regenerate", False))
secret = secrets.token_urlsafe(32) if regenerate else None
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
WHEN $4=0 THEN NULL ELSE to_timestamp($4) END,
privilege_separation=COALESCE($5::boolean, privilege_separation),
secret_hash=COALESCE($6::text, secret_hash), updated_at=now()
FROM principals p WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2
RETURNING t.comment, t.privilege_separation,
extract(epoch from t.expires_at)::bigint AS expire""",
userid,
tokenid,
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:
raise ApiError(404, "API token does not exist")
result = _token_info(row)
if secret is not None:
result.update({"full-tokenid": f"{userid}!{tokenid}", "value": secret})
return result
async def token_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
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,
tokenid,
)
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
+162
View File
@@ -0,0 +1,162 @@
"""First read/login semantic service handlers."""
from __future__ import annotations
import json
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.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
def _database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def build_core_handlers(settings: Settings) -> HandlerRegistry:
registry = HandlerRegistry()
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"])
username = str(values["username"])
password = str(values["password"])
row = await _database(request).pool.fetchrow(
"SELECT name, password_hash FROM principals WHERE name=$1", username
)
if (
row is None
or row["password_hash"] is None
or not verify_secret(password, str(row["password_hash"]))
):
raise ApiError(401, "authentication failure")
key = settings.ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(username, key)
return {
"username": username,
"ticket": ticket,
"CSRFPreventionToken": csrf_token(ticket, key),
"cap": {"vms": {"VM.Audit": 1, "VM.PowerMgmt": 1}},
}
async def nodes(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await _database(request).pool.fetch(
"SELECT name AS node, status FROM nodes ORDER BY name"
)
return [{"node": str(row["node"]), "status": str(row["status"])} for row in rows]
async def node_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(cast(dict[str, Any], inputs["values"])["node"])
row = await _database(request).pool.fetchrow(
"SELECT name, status FROM nodes WHERE name=$1", node
)
if row is None:
raise ApiError(404, "node does not exist")
return {
"status": str(row["status"]),
"node": str(row["name"]),
"uptime": 0,
"cpu": 0.0,
"memory": {"used": 0, "total": 0},
}
async def resources(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await _database(request).pool.fetch(
"""SELECT r.kind AS type, r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
ORDER BY r.kind, r.external_id"""
)
result: list[dict[str, Any]] = []
for row in rows:
raw_state = row["state"]
state = json.loads(raw_state) if isinstance(raw_state, str) else dict(raw_state)
result.append(
{
"type": str(row["type"]),
"id": f"{row['type']}/{row['external_id']}",
"node": str(row["node"]),
**state,
}
)
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)
+865
View File
@@ -0,0 +1,865 @@
"""Basic persistent QEMU and task semantic handlers."""
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.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
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_qemu_handlers(registry: HandlerRegistry) -> None:
async def qemu_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='qemu' ORDER BY r.external_id::integer""",
node,
)
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(
"""SELECT r.state, v.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN virtual_machines v ON v.resource_id=r.id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine does not exist")
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
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='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine 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} VM while it is {current}") from error
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"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
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", "archive", "start"}
}
return await _create_task(
request,
node=node,
vmid=str(vmid),
task_type="qemu-create",
payload={"node": node, "vmid": vmid, "config": config},
)
async def update(request: Request, inputs: dict[str, Any], *, asynchronous: bool) -> str | 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, v.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN virtual_machines v ON v.resource_id=r.id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine does not exist")
control = {"node", "vmid", "digest", "delete", "revert", "skiplock", "background_delay"}
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 ""
if asynchronous:
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-update",
payload={
"node": node,
"vmid": vmid,
"resource_id": str(row["id"]),
"changes": changes,
"delete": delete,
},
)
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 virtual_machines SET config=$2::jsonb
WHERE resource_id=$1""",
row["id"],
json.dumps(config, sort_keys=True),
)
return None
async def update_async(request: Request, inputs: dict[str, Any]) -> str:
result = await update(request, inputs, asynchronous=True)
if not isinstance(result, str):
raise RuntimeError("async QEMU update did not create a task")
return result
async def update_sync(request: Request, inputs: dict[str, Any]) -> None:
await update(request, inputs, asynchronous=False)
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='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine does not exist")
if str(_state(row["state"]).get("status")) != "stopped":
raise ApiError(409, "cannot delete a running virtual machine")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-delete",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
)
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 reset(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("reset", 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 snapshot_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _qemu_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 _qemu_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"qemu-{operation}",
payload={
"node": node,
"vmid": vmid,
"resource_id": str(resource["id"]),
"snapname": snapname,
"description": str(values.get("description", "")),
"vmstate": bool(values.get("vmstate", False)),
"start": bool(values.get("start", False)),
},
)
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 _qemu_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="qemu-clone",
payload={
"source_resource_id": str(source["id"]),
"source_vmid": vmid,
"node": target,
"vmid": int(newid),
"name": values.get("name"),
"description": values.get("description"),
"full": bool(values.get("full", False)),
},
)
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 = 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 _qemu_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="qemu-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 _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"])
resource = await _qemu_resource(request, node, vmid)
config = _state(resource["config"])
if disk not in config:
raise ApiError(400, f"disk {disk} does not exist")
current = _disk_size_bytes(str(config[disk]))
size = _resize_bytes(str(values["size"]), current)
config[disk] = _replace_disk_size(str(config[disk]), size)
status = await _database(request).pool.execute(
"""UPDATE virtual_machines SET config=$2::jsonb
WHERE resource_id=$1""",
resource["id"],
json.dumps(config, sort_keys=True),
)
if status != "UPDATE 1":
raise ApiError(409, "configuration changed concurrently")
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),
)
await _database(request).pool.execute(
"""INSERT INTO vm_disks(id,resource_id,device,storage_id,size_bytes)
VALUES(gen_random_uuid(),$1,$2,$3,$4)
ON CONFLICT(resource_id,device) DO UPDATE SET size_bytes=EXCLUDED.size_bytes""",
resource["id"],
disk,
str(config[disk]).split(":", 1)[0],
size,
)
async def move_disk(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
resource = await _qemu_resource(request, node, vmid)
if disk not in _state(resource["config"]):
raise ApiError(400, f"disk {disk} does not exist")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-move-disk",
payload={
"resource_id": str(resource["id"]),
"disk": disk,
"storage": str(values.get("storage") or "local-lvm"),
"target_vmid": int(values.get("target-vmid") or vmid),
"target_disk": str(values.get("target-disk") or disk),
"delete": bool(values.get("delete", True)),
},
)
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"])
)
return [
{"upid": task.upid, "status": task.status, "type": task.task_type} for task in tasks
]
async def task_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
task = await TaskRepository(_database(request).pool).get_by_upid(
str(_values(inputs)["upid"])
)
if task is None:
raise ApiError(404, "task does not exist")
result: dict[str, Any] = {
"upid": task.upid,
"status": "stopped" if task.status in {"success", "error", "cancelled"} else "running",
"progress": task.progress,
}
if task.status in {"success", "error", "cancelled"}:
result["exitstatus"] = "OK" if task.status == "success" else task.status.upper()
return result
async def task_log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
repository = TaskRepository(_database(request).pool)
task = await repository.get_by_upid(str(_values(inputs)["upid"]))
if task is None:
raise ApiError(404, "task does not exist")
return [
{"n": index + 1, "t": message}
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", "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)
registry.register("/nodes/{node}/qemu/{vmid}/status/reboot", "POST", reboot)
registry.register("/nodes/{node}/qemu/{vmid}/status/reset", "POST", reset)
registry.register("/nodes/{node}/qemu/{vmid}/status/suspend", "POST", suspend)
registry.register("/nodes/{node}/qemu/{vmid}/status/resume", "POST", resume)
registry.register("/nodes/{node}/qemu/{vmid}/snapshot", "GET", snapshot_list)
registry.register("/nodes/{node}/qemu/{vmid}/snapshot", "POST", snapshot_create)
registry.register("/nodes/{node}/qemu/{vmid}/snapshot/{snapname}", "GET", snapshot_get)
registry.register("/nodes/{node}/qemu/{vmid}/snapshot/{snapname}", "DELETE", snapshot_delete)
registry.register(
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/config", "GET", snapshot_config
)
registry.register(
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/config", "PUT", snapshot_update
)
registry.register(
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/rollback", "POST", snapshot_rollback
)
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(
request: Request,
*,
node: str,
vmid: str,
task_type: str,
payload: dict[str, Any],
) -> str:
database = _database(request)
worker_type = {
"qemu-create": "qmcreate",
"qemu-delete": "qmdestroy",
"qemu-update": "qmconfig",
"qemu-snapshot-create": "qmsnapshot",
"qemu-snapshot-delete": "qmdelsnapshot",
"qemu-snapshot-rollback": "qmrollback",
"qemu-clone": "qmclone",
"qemu-migrate": "qmigrate",
"qemu-remote-migrate": "qmremote",
"qemu-move-disk": "qmmove",
}[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"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 _qemu_resource(request: Request, node: str, vmid: str) -> Any:
row = await _database(request).pool.fetchrow(
"""SELECT r.id, r.state, v.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN virtual_machines v ON v.resource_id=r.id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine 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='qemu' 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
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)
def _size_bytes(value: str) -> int:
match = _SIZE_RE.fullmatch(value.strip())
if match is None:
raise ApiError(400, f"invalid disk size: {value}")
units = {"": 1, "K": 2**10, "M": 2**20, "G": 2**30, "T": 2**40}
return int(match.group("value")) * units[match.group("unit").upper()]
def _disk_size_bytes(value: str) -> int:
for part in value.split(","):
if part.startswith("size="):
return _size_bytes(part.removeprefix("size="))
return 0
def _resize_bytes(value: str, current: int) -> int:
if value.startswith("+"):
return current + _size_bytes(value[1:])
result = _size_bytes(value)
if result < current:
raise ApiError(400, "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)
+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)
+63
View File
@@ -0,0 +1,63 @@
"""Application resource ownership."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Protocol
from fastapi import FastAPI
from app.config import Settings
from app.db.pool import AsyncpgDatabase, Database
DatabaseFactory = Callable[[Settings], Database]
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
class LifespanWorker(Protocol):
async def run(self) -> None: ...
def stop(self) -> None: ...
WorkerFactory = Callable[[Database], LifespanWorker]
def create_lifespan(
settings: Settings,
database_factory: DatabaseFactory,
worker_factories: tuple[WorkerFactory, ...] = (),
) -> Lifespan:
"""Build a lifespan context so tests can inject a database implementation."""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
database = database_factory(settings)
await database.connect()
app.state.database = database
try:
from app.vsphere.seed import seed_vsphere_inventory
await seed_vsphere_inventory(database, force=False)
except Exception:
pass
workers = tuple(factory(database) for factory in worker_factories)
worker_tasks = tuple(asyncio.create_task(worker.run()) for worker in workers)
try:
yield
finally:
for worker in workers:
worker.stop()
if worker_tasks:
await asyncio.gather(*worker_tasks)
await database.close()
return lifespan
def default_database_factory(settings: Settings) -> Database:
"""Create the production asyncpg adapter."""
return AsyncpgDatabase(settings)
+40
View File
@@ -0,0 +1,40 @@
"""Structured logging configuration with safe JSON output."""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from typing import Any
class JsonFormatter(logging.Formatter):
"""Serialize standard records and selected structured attributes as JSON."""
_fields = ("request_id", "method", "path", "status", "duration_ms")
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for field in self._fields:
value = getattr(record, field, None)
if value is not None:
payload[field] = value
if record.exc_info is not None:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def configure_logging(level: str) -> None:
"""Configure the root logger once for the process."""
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level.upper())
+136
View File
@@ -0,0 +1,136 @@
"""FastAPI application factory and ASGI entry point."""
from __future__ import annotations
import asyncio
from typing import cast
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.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.vsphere.rest import vsphere_rest_router
from app.vsphere.soap.router import router as vsphere_soap_router
from app.web.routes import router as web_router
def create_app(
settings: Settings | None = None,
database_factory: DatabaseFactory = default_database_factory,
handlers: HandlerRegistry | None = None,
worker_factories: tuple[WorkerFactory, ...] | None = None,
) -> FastAPI:
"""Create an isolated application instance with explicit resource factories."""
resolved = settings or get_settings()
configure_logging(resolved.log_level)
resolved_workers = worker_factories
pve_stub = bool(getattr(resolved, "enable_pve_stub", False) and resolved.contract_snapshot)
if resolved_workers is None and pve_stub and handlers is None:
def task_worker(database: Database) -> TaskWorker:
adapter = cast(AsyncpgDatabase, database)
repository = TaskRepository(adapter.pool)
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": 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,
)
resolved_workers = (task_worker,)
app = FastAPI(
title=resolved.app_name,
version="0.1.0",
openapi_tags=openapi_tag_metadata(include_pve=pve_stub),
lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()),
)
app.state.settings = resolved
app.state.contract_swap_lock = asyncio.Lock()
app.state.vsphere_contract_major = 9
app.state.runtime_source_version = "8.0.2"
app.add_middleware(RequestContextMiddleware, header_name=resolved.request_id_header)
from app.vsphere.rest.version_gate import VsphereVersionGateMiddleware
app.add_middleware(VsphereVersionGateMiddleware)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.add_exception_handler(ApiError, api_error_handler)
# Native vSphere surface (survives contract hot-swap).
from app.vsphere.soap.pbm import router as vsphere_pbm_router
app.include_router(vsphere_rest_router)
app.include_router(vsphere_soap_router)
app.include_router(vsphere_pbm_router)
app.include_router(web_router)
app.include_router(health_router)
if pve_stub and resolved.contract_snapshot is not None:
snapshot = Snapshot.model_validate_json(resolved.contract_snapshot.read_bytes())
resolved_handlers = handlers or build_core_handlers(resolved)
apply_runtime_contract(
app,
snapshot,
handlers=resolved_handlers,
store_root=contract_store_root(resolved),
fallback=resolved.contract_fallback,
settings=resolved,
require_evidence_match=True,
register_admin=True,
)
return app
app = create_app()
+1
View File
@@ -0,0 +1 @@
"""Health, metrics, and tracing adapters."""
+37
View File
@@ -0,0 +1,37 @@
"""Kubernetes-compatible health endpoints."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Response, status
from pydantic import BaseModel
from app.db.pool import Database
from app.dependencies import get_database
router = APIRouter(prefix="/health", tags=["Simulator"])
class HealthResponse(BaseModel):
status: str
@router.get("/live", response_model=HealthResponse)
async def live() -> HealthResponse:
"""Report process liveness without checking dependencies."""
return HealthResponse(status="ok")
@router.get("/ready", response_model=HealthResponse)
async def ready(
response: Response,
database: Annotated[Database, Depends(get_database)],
) -> HealthResponse:
"""Report whether the required database dependency is usable."""
if await database.is_ready():
return HealthResponse(status="ok")
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return HealthResponse(status="unavailable")
+1
View File
@@ -0,0 +1 @@
"""Authentication, secrets, and authorization boundaries."""
+92
View File
@@ -0,0 +1,92 @@
"""Capability-driven ACL evaluation with token privilege separation."""
from __future__ import annotations
from dataclasses import dataclass
from app.contracts.model import Permissions
@dataclass(frozen=True, slots=True)
class Realm:
name: str
kind: str
@dataclass(frozen=True, slots=True)
class Principal:
name: str
realm: str
@dataclass(frozen=True, slots=True)
class Role:
name: str
privileges: frozenset[str]
@dataclass(frozen=True, slots=True)
class AclEntry:
principal: str
path: str
privileges: frozenset[str]
propagate: bool = True
def _ancestors(path: str) -> tuple[str, ...]:
parts = [part for part in path.split("/") if part]
return tuple(["/"] + ["/" + "/".join(parts[:index]) for index in range(1, len(parts) + 1)])
def effective_privileges(
principal: str, path: str, entries: tuple[AclEntry, ...]
) -> frozenset[str]:
privileges: set[str] = set()
for entry in entries:
if entry.principal != principal or entry.path not in _ancestors(path):
continue
if entry.path == path or entry.propagate:
privileges.update(entry.privileges)
return frozenset(privileges)
def authorize(
principal: str,
path: str,
required: frozenset[str],
entries: tuple[AclEntry, ...],
*,
token_privileges: frozenset[str] | None = None,
require_all: bool = True,
) -> bool:
privileges = effective_privileges(principal, path, entries)
if token_privileges is not None:
privileges &= token_privileges
return required <= privileges if require_all else bool(required & privileges)
@dataclass(frozen=True, slots=True)
class CapabilityRequirement:
path: str
privileges: frozenset[str]
require_all: bool = True
def requirement_from_contract(
permissions: Permissions | None, parameters: dict[str, str]
) -> CapabilityRequirement | None:
if permissions is None or not permissions.expression:
return None
check = permissions.expression.get("check")
if not isinstance(check, list) or len(check) < 3 or check[0] != "perm":
return None
raw_path = str(check[1])
for name, value in parameters.items():
raw_path = raw_path.replace(f"{{{name}}}", value).replace(f"<{name}>", value)
raw_privileges = check[2]
if not isinstance(raw_privileges, list):
return None
require_all = not (len(check) >= 4 and check[3] == "any")
return CapabilityRequirement(
raw_path, frozenset(str(item) for item in raw_privileges), require_all
)
+136
View File
@@ -0,0 +1,136 @@
"""Password, ticket, CSRF, and API-token primitives."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import re
import secrets
import time
from dataclasses import dataclass
from starlette.responses import Response
class AuthenticationError(ValueError):
pass
def _b64(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def _unb64(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def hash_secret(secret: str, *, salt: bytes | None = None) -> str:
actual_salt = salt or secrets.token_bytes(16)
digest = hashlib.scrypt(secret.encode(), salt=actual_salt, n=2**14, r=8, p=1, dklen=32)
return f"scrypt$16384$8$1${_b64(actual_salt)}${_b64(digest)}"
def verify_secret(secret: str, encoded: str) -> bool:
try:
algorithm, n, r, p, salt, expected = encoded.split("$")
if algorithm != "scrypt":
return False
actual = hashlib.scrypt(
secret.encode(), salt=_unb64(salt), n=int(n), r=int(r), p=int(p), dklen=32
)
return hmac.compare_digest(actual, _unb64(expected))
except (ValueError, TypeError):
return False
@dataclass(frozen=True, slots=True)
class TicketClaims:
principal: str
issued_at: int
expires_at: int
nonce: str
def issue_ticket(principal: str, key: bytes, *, now: int | None = None, ttl: int = 7200) -> str:
issued = int(time.time() if now is None else now)
claims = {
"exp": issued + ttl,
"iat": issued,
"nonce": _b64(secrets.token_bytes(12)),
"principal": principal,
}
payload = _b64(json.dumps(claims, sort_keys=True, separators=(",", ":")).encode())
signature = _b64(hmac.digest(key, payload.encode(), "sha256"))
return f"PVE:{payload}.{signature}"
def verify_ticket(ticket: str, key: bytes, *, now: int | None = None) -> TicketClaims:
try:
prefix, signed = ticket.split(":", 1)
payload, signature = signed.split(".", 1)
if prefix != "PVE" or not hmac.compare_digest(
_unb64(signature), hmac.digest(key, payload.encode(), "sha256")
):
raise AuthenticationError("invalid ticket")
data = json.loads(_unb64(payload))
claims = TicketClaims(
principal=str(data["principal"]),
issued_at=int(data["iat"]),
expires_at=int(data["exp"]),
nonce=str(data["nonce"]),
)
except (ValueError, KeyError, json.JSONDecodeError) as error:
raise AuthenticationError("invalid ticket") from error
current = int(time.time() if now is None else now)
if claims.expires_at < current or claims.issued_at > current + 60:
raise AuthenticationError("ticket expired or not yet valid")
return claims
def csrf_token(ticket: str, key: bytes) -> str:
return _b64(hmac.digest(key, b"csrf:" + ticket.encode(), "sha256"))
def verify_csrf(ticket: str, token: str, key: bytes) -> bool:
return hmac.compare_digest(csrf_token(ticket, key), token)
def set_ticket_cookie(response: Response, ticket: str, *, secure: bool = True) -> None:
response.set_cookie(
"PVEAuthCookie",
ticket,
httponly=True,
secure=secure,
samesite="strict",
path="/",
)
@dataclass(frozen=True, slots=True)
class ApiToken:
principal: str
token_id: str
secret: str
TOKEN_PATTERN = re.compile(r"^PVEAPIToken=([^!=\s]+![^=\s]+)=([^\s]+)$")
def parse_api_token(header: str) -> ApiToken:
match = TOKEN_PATTERN.fullmatch(header)
if match is None:
raise AuthenticationError("invalid API token")
identity, secret = match.groups()
principal, token_id = identity.rsplit("!", 1)
return ApiToken(principal, token_id, secret)
SECRET_RE = re.compile(r"(PVEAPIToken=[^=\s]+=)[^\s]+|(password|secret|token)=([^&\s]+)", re.I)
def redact_secrets(value: str) -> str:
return SECRET_RE.sub(
lambda match: (match.group(1) or f"{match.group(2)}=") + "[REDACTED]", value
)
+1
View File
@@ -0,0 +1 @@
"""Persistent deterministic simulation services."""
+61
View File
@@ -0,0 +1,61 @@
"""Injectable simulation clocks; task leases deliberately do not use these."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from typing import Protocol
class Clock(Protocol):
async def now(self) -> datetime: ...
async def sleep(self, seconds: float) -> None: ...
class RealClock:
async def now(self) -> datetime:
return datetime.now(UTC)
async def sleep(self, seconds: float) -> None:
await asyncio.sleep(seconds)
class AcceleratedClock:
def __init__(self, scale: float) -> None:
if scale <= 0:
raise ValueError("clock scale must be positive")
self._scale = scale
async def now(self) -> datetime:
return datetime.now(UTC)
async def sleep(self, seconds: float) -> None:
await asyncio.sleep(seconds / self._scale)
class ManualClock:
def __init__(self, initial: datetime) -> None:
if initial.tzinfo is None:
raise ValueError("manual clock requires timezone-aware time")
self._now = initial
self._condition = asyncio.Condition()
async def now(self) -> datetime:
async with self._condition:
return self._now
async def sleep(self, seconds: float) -> None:
if seconds < 0:
raise ValueError("sleep duration cannot be negative")
async with self._condition:
target = self._now + timedelta(seconds=seconds)
await self._condition.wait_for(lambda: self._now >= target)
async def advance(self, seconds: float) -> datetime:
if seconds < 0:
raise ValueError("clock cannot move backwards")
async with self._condition:
self._now += timedelta(seconds=seconds)
self._condition.notify_all()
return self._now
+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))
+49
View File
@@ -0,0 +1,49 @@
"""Seeded deterministic fault-rule evaluation."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class FaultContext:
method: str
path: str
principal: str | None = None
node: str | None = None
vmid: str | None = None
call_number: int = 1
@dataclass(frozen=True, slots=True)
class FaultRule:
kind: str
probability: float = 1.0
method: str | None = None
path_prefix: str | None = None
principal: str | None = None
node: str | None = None
vmid: str | None = None
call_number: int | None = None
def __post_init__(self) -> None:
if not 0 <= self.probability <= 1:
raise ValueError("fault probability must be between zero and one")
def matches(rule: FaultRule, context: FaultContext, seed: int) -> bool:
filters = (
(rule.method, context.method),
(rule.principal, context.principal),
(rule.node, context.node),
(rule.vmid, context.vmid),
(rule.call_number, context.call_number),
)
if any(expected is not None and expected != actual for expected, actual in filters):
return False
if rule.path_prefix is not None and not context.path.startswith(rule.path_prefix):
return False
material = f"{seed}:{rule.kind}:{context.method}:{context.path}:{context.call_number}"
sample = int.from_bytes(hashlib.sha256(material.encode()).digest()[:8], "big") / 2**64
return sample < rule.probability
+863
View File
@@ -0,0 +1,863 @@
"""Deterministic idempotent simulation seed profiles."""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
import asyncpg # type: ignore[import-untyped]
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)
class SeedNode:
id: uuid.UUID
name: str
status: str
@dataclass(frozen=True, slots=True)
class SeedResource:
id: uuid.UUID
node_id: uuid.UUID
kind: str
external_id: str
state: dict[str, object]
@dataclass(frozen=True, slots=True)
class SeedTask:
id: uuid.UUID
upid: str
task_type: str
payload: dict[str, object]
@dataclass(frozen=True, slots=True)
class SeedProfile:
name: str
nodes: tuple[SeedNode, ...]
resources: tuple[SeedResource, ...]
tasks: tuple[SeedTask, ...] = ()
def logical_state(self) -> dict[str, object]:
nodes = [{"name": node.name, "status": node.status} for node in self.nodes]
names = {node.id: node.name for node in self.nodes}
resources = [
{
"kind": resource.kind,
"external_id": resource.external_id,
"node": names[resource.node_id],
"state": resource.state,
}
for resource in self.resources
]
tasks = [
{"upid": task.upid, "task_type": task.task_type, "status": "success"}
for task in self.tasks
]
return {"profile": self.name, "nodes": nodes, "resources": resources, "tasks": tasks}
def stable_id(name: str) -> uuid.UUID:
return uuid.uuid5(NAMESPACE, name)
def _string_list(state: dict[str, object], key: str) -> tuple[str, ...]:
value = state.get(key, [])
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise ValueError(f"seed state {key} must be a string list")
return tuple(value)
def _node(name: str, status: str = "online") -> SeedNode:
return SeedNode(stable_id(f"node:{name}"), name, status)
def _resource(
node: SeedNode, kind: str, external_id: str, state: dict[str, object]
) -> SeedResource:
return SeedResource(stable_id(f"{kind}:{external_id}"), node.id, kind, external_id, state)
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:pve01:0000000{index}:0000000{index}:6500000{index}:"
f"{task_type}:{resource_id}:root@pam:",
task_type,
{"resource_id": resource_id, "seeded": True},
)
def small_profile() -> SeedProfile:
node = _node("pve01")
resources = (
_resource(node, "qemu", "100", {"name": "demo", "status": "stopped"}),
_resource(node, "qemu", "101", {"name": "worker", "status": "stopped"}),
_resource(node, "lxc", "200", {"name": "service", "status": "stopped"}),
_resource(node, "storage", "local", {"content": ["iso", "backup"], "status": "available"}),
_resource(
node, "storage", "local-lvm", {"content": ["images", "rootdir"], "status": "available"}
),
)
tasks = (_completed_task(1, "qmstart", "100"), _completed_task(2, "qmstop", "100"))
return SeedProfile("small", (node,), resources, tasks)
def medium_profile() -> SeedProfile:
nodes = tuple(_node(f"pve{index}") for index in range(1, 4))
resources: list[SeedResource] = []
for vmid in range(100, 150):
node = nodes[(vmid - 100) % len(nodes)]
resources.append(
_resource(node, "qemu", str(vmid), {"name": f"vm-{vmid}", "status": "stopped"})
)
for vmid in range(200, 220):
node = nodes[(vmid - 200) % len(nodes)]
resources.append(
_resource(node, "lxc", str(vmid), {"name": f"ct-{vmid}", "status": "stopped"})
)
for node in nodes:
resources.append(
_resource(
node,
"storage",
f"local-{node.name}",
{"content": ["images"], "shared": False, "status": "available"},
)
)
resources.append(
_resource(
nodes[0],
"storage",
"shared",
{"content": ["images", "backup"], "shared": True, "status": "available"},
)
)
resources.append(_resource(nodes[0], "pool", "development", {"members": ["100", "101", "200"]}))
tasks = tuple(_completed_task(index, "qmstart", str(99 + index)) for index in range(1, 11))
return SeedProfile("medium", nodes, tuple(resources), tasks)
def large_profile(*, node_count: int = 10, resource_count: int = 10_000) -> SeedProfile:
if node_count < 1 or resource_count < 1:
raise ValueError("large profile counts must be positive")
nodes = tuple(_node(f"pve{index}") for index in range(1, node_count + 1))
resources = tuple(
_resource(
nodes[index % node_count],
"qemu" if index % 4 else "lxc",
str(100 + index),
{"name": f"guest-{100 + index}", "status": "stopped"},
)
for index in range(resource_count)
)
return SeedProfile("large", nodes, resources)
def ha_demo_profile() -> SeedProfile:
profile = medium_profile()
resources = (
*profile.resources,
_resource(profile.nodes[0], "ha", "vm:100", {"state": "started", "group": "primary"}),
)
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(
_resource(
next(node for node in profile.nodes if node.id == resource.node_id),
resource.kind,
resource.external_id,
{**resource.state, "status": "offline", "error": "simulated I/O failure"}
if resource.kind == "storage" and resource.external_id == "local-lvm"
else resource.state,
)
for resource in profile.resources
)
return SeedProfile("broken-storage", profile.nodes, resources, profile.tasks)
def build_profile(
name: str, *, large_nodes: int = 10, large_resources: int = 10_000
) -> SeedProfile:
if name == "small":
return small_profile()
if name == "medium":
return medium_profile()
if name == "large":
return large_profile(node_count=large_nodes, resource_count=large_resources)
if name == "ha-demo":
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(
"""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, 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)
VALUES($1, $2, $3, $4, $5::jsonb)""",
[
(
resource.id,
resource.node_id,
resource.kind,
resource.external_id,
json.dumps(resource.state, sort_keys=True),
)
for resource in profile.resources
],
)
qemu = [resource for resource in profile.resources if resource.kind == "qemu"]
if qemu:
await connection.executemany(
"""INSERT INTO virtual_machines(resource_id, cluster_id, vmid, config)
VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3::jsonb)""",
[
(
resource.id,
int(resource.external_id),
json.dumps(resource.state, sort_keys=True),
)
for resource in qemu
],
)
containers = [resource for resource in profile.resources if resource.kind == "lxc"]
if containers:
await connection.executemany(
"""INSERT INTO containers(resource_id, cluster_id, vmid, config)
VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3::jsonb)""",
[
(
resource.id,
int(resource.external_id),
json.dumps(resource.state, sort_keys=True),
)
for resource in containers
],
)
storages = [resource for resource in profile.resources if resource.kind == "storage"]
if storages:
await connection.executemany(
"""INSERT INTO storages(
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,
_storage_type(resource),
bool(resource.state.get("shared", False)),
*_storage_capacity(resource),
json.dumps(resource.state, sort_keys=True),
)
for resource in storages
],
)
contents = [
(
stable_id(f"content:{resource.external_id}:{content}"),
resource.id,
f"{resource.external_id}:{content}/seeded",
str(content),
)
for resource in storages
for content in _string_list(resource.state, "content")
]
if contents:
await connection.executemany(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type
) VALUES($1, $2, $3, $4)""",
contents,
)
pools = [resource for resource in profile.resources if resource.kind == "pool"]
if pools:
await connection.executemany(
"""INSERT INTO pools(id, cluster_id, pool_id, metadata)
VALUES($1, 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f', $2, $3::jsonb)""",
[
(resource.id, resource.external_id, json.dumps(resource.state, sort_keys=True))
for resource in pools
],
)
members = [
(pool.id, member.id)
for pool in pools
for external_id in _string_list(pool.state, "members")
for member in profile.resources
if member.external_id == external_id and member.kind in {"qemu", "lxc"}
]
if members:
await connection.executemany(
"INSERT INTO pool_members(pool_id, resource_id) VALUES($1, $2)", members
)
if profile.tasks:
await connection.executemany(
"""INSERT INTO tasks(id, upid, status, payload, task_type, progress, result)
VALUES($1, $2, 'success', $3::jsonb, $4, 100, '{\"seeded\":true}'::jsonb)""",
[
(task.id, task.upid, json.dumps(task.payload, sort_keys=True), task.task_type)
for task in profile.tasks
],
)
await connection.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES($1, 'root@pam', $2, 'pam')
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
realm_name=EXCLUDED.realm_name""",
stable_id("principal:root@pam"),
hash_secret("secret", salt=b"pve-simulator-v1"),
)
await connection.execute(
"""INSERT INTO api_tokens(principal_id, token_id, secret_hash, privileges)
VALUES($1, 'automation', $2, $3)
ON CONFLICT (principal_id, token_id) DO UPDATE
SET secret_hash=EXCLUDED.secret_hash, privileges=EXCLUDED.privileges""",
stable_id("principal:root@pam"),
hash_secret("automation-secret", salt=b"pve-token-seed-v1"),
["VM.Audit", "VM.PowerMgmt", "Sys.Audit"],
)
auditor_id = stable_id("principal:auditor@pve")
await connection.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES($1, 'auditor@pve', $2, 'pve')
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
realm_name=EXCLUDED.realm_name""",
auditor_id,
hash_secret("auditor-secret", salt=b"pve-auditor-v1"),
)
await connection.execute(
"""INSERT INTO roles(name, privileges)
VALUES('PVEAuditor', $1)
ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""",
["Sys.Audit", "VM.Audit"],
)
await connection.execute(
"DELETE FROM acl_entries WHERE principal_id=$1 AND role_name='PVEAuditor'",
auditor_id,
)
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
RETURNING id""",
stable_id("group:auditors"),
)
await connection.execute(
"""INSERT INTO identity_group_members(group_id, principal_id)
VALUES($1, $2) ON CONFLICT DO NOTHING""",
auditor_group_id,
auditor_id,
)
await connection.execute(
"""INSERT INTO group_acl_entries(group_id, role_name, path, propagate)
VALUES($1, 'PVEAuditor', '/', true)
ON CONFLICT (group_id, role_name, path) DO UPDATE
SET propagate=EXCLUDED.propagate""",
auditor_group_id,
)
await connection.execute(
"""INSERT INTO api_tokens(principal_id, token_id, secret_hash, privileges)
VALUES($1, 'readonly', $2, $3)
ON CONFLICT (principal_id, token_id) DO UPDATE
SET secret_hash=EXCLUDED.secret_hash, privileges=EXCLUDED.privileges""",
auditor_id,
hash_secret("readonly-secret", salt=b"pve-readonly-v1"),
["Sys.Audit", "VM.Audit"],
)
for username, role_name, privileges, acl_path, token_id, token_secret in (
(
"operator@pve",
"PVEVMOperator",
["VM.Audit", "VM.PowerMgmt"],
"/vms",
"operator",
"operator-secret",
),
(
"storage@pve",
"PVEStorageUser",
["Datastore.Audit", "Datastore.AllocateSpace"],
"/storage",
"storage",
"storage-secret",
),
):
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,
)
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,
)
await connection.execute(
"""INSERT INTO api_tokens(principal_id, token_id, secret_hash, privileges)
VALUES($1, $2, $3, $4)
ON CONFLICT (principal_id, token_id) DO UPDATE
SET secret_hash=EXCLUDED.secret_hash, privileges=EXCLUDED.privileges,
privilege_separation=true""",
principal_id,
token_id,
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(
database_url: str,
profile_name: str = "small",
*,
large_nodes: int = 10,
large_resources: int = 10_000,
) -> dict[str, object]:
connection = await asyncpg.connect(database_url)
try:
profile = build_profile(
profile_name, large_nodes=large_nodes, large_resources=large_resources
)
await apply_seed(connection, profile)
return profile.logical_state()
finally:
await connection.close()
+44
View File
@@ -0,0 +1,44 @@
"""Apply a deterministic simulation seed."""
import asyncio
import json
import os
from app.config import get_settings
from app.db.pool import AsyncpgDatabase
from app.vsphere.seed import seed_vsphere_inventory
async def run() -> None:
settings = get_settings()
enable_pve = os.getenv("ENABLE_PVE_STUB", "false").lower() in {"1", "true", "yes"}
proxmox_stub: dict | None = None
if enable_pve:
from app.simulation.seed import seed_url
proxmox_stub = await seed_url(
settings.database_url.get_secret_value(),
os.getenv("SEED_PROFILE", "small"),
large_nodes=int(os.getenv("SEED_LARGE_NODES", "10")),
large_resources=int(os.getenv("SEED_LARGE_RESOURCES", "10000")),
)
database = AsyncpgDatabase(settings)
await database.connect()
try:
vsphere = await seed_vsphere_inventory(
database,
force=True,
profile=os.getenv("SEED_VSPHERE_PROFILE", "large"),
large_hosts=int(os.getenv("SEED_VSPHERE_LARGE_HOSTS", "10")),
large_vms=int(os.getenv("SEED_VSPHERE_LARGE_VMS", "1000")),
)
finally:
await database.close()
payload = {"vsphere": vsphere}
if proxmox_stub is not None:
payload["proxmox_stub"] = proxmox_stub
print(json.dumps(payload, sort_keys=True))
if __name__ == "__main__":
asyncio.run(run())
+67
View File
@@ -0,0 +1,67 @@
"""Explicit virtual-machine state machine."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from app.simulation.clock import Clock
class VmState(StrEnum):
STOPPED = "stopped"
STARTING = "starting"
RUNNING = "running"
PAUSING = "pausing"
PAUSED = "paused"
RESUMING = "resuming"
STOPPING = "stopping"
MIGRATING = "migrating"
SNAPSHOTTING = "snapshotting"
BACKING_UP = "backing_up"
ERROR = "error"
class InvalidTransitionError(ValueError):
pass
TRANSITIONS: dict[tuple[VmState, str], tuple[VmState, VmState]] = {
(VmState.STOPPED, "start"): (VmState.STARTING, VmState.RUNNING),
(VmState.RUNNING, "stop"): (VmState.STOPPING, VmState.STOPPED),
(VmState.RUNNING, "shutdown"): (VmState.STOPPING, VmState.STOPPED),
(VmState.RUNNING, "reboot"): (VmState.STOPPING, VmState.RUNNING),
(VmState.RUNNING, "reset"): (VmState.STOPPING, VmState.RUNNING),
(VmState.RUNNING, "suspend"): (VmState.PAUSING, VmState.PAUSED),
(VmState.RUNNING, "pause"): (VmState.PAUSING, VmState.PAUSED),
(VmState.PAUSED, "resume"): (VmState.RESUMING, VmState.RUNNING),
(VmState.RUNNING, "migrate"): (VmState.MIGRATING, VmState.RUNNING),
(VmState.STOPPED, "migrate"): (VmState.MIGRATING, VmState.STOPPED),
(VmState.RUNNING, "snapshot"): (VmState.SNAPSHOTTING, VmState.RUNNING),
(VmState.STOPPED, "snapshot"): (VmState.SNAPSHOTTING, VmState.STOPPED),
(VmState.RUNNING, "backup"): (VmState.BACKING_UP, VmState.RUNNING),
(VmState.STOPPED, "backup"): (VmState.BACKING_UP, VmState.STOPPED),
}
@dataclass(frozen=True, slots=True)
class Transition:
operation: str
before: VmState
intermediate: VmState
after: VmState
def plan_transition(state: VmState, operation: str) -> Transition:
states = TRANSITIONS.get((state, operation))
if states is None:
raise InvalidTransitionError(f"cannot {operation} VM while it is {state}")
return Transition(operation, state, states[0], states[1])
async def execute_transition(
state: VmState, operation: str, clock: Clock, duration_seconds: float
) -> tuple[VmState, VmState]:
transition = plan_transition(state, operation)
await clock.sleep(duration_seconds)
return transition.intermediate, transition.after
+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()))
+1
View File
@@ -0,0 +1 @@
"""Durable asynchronous task engine."""
+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))
+328
View File
@@ -0,0 +1,328 @@
"""Worker semantics for asynchronous QEMU 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 qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
async def execute(task: Task) -> dict[str, Any]:
operation = task.task_type.removeprefix("qemu-")
if operation == "create":
return await _create(repository, task)
if operation == "clone":
return await _clone(repository, task)
resource_id = uuid.UUID(str(task.payload["resource_id"]))
if operation == "update":
return await _update(repository, task, 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)
if operation == "move-disk":
return await _move_disk(repository, task, resource_id)
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")
raw = row["state"]
state = json.loads(raw) if isinstance(raw, str) else dict(raw)
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"VM {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"VM {operation} completed")
return {"status": str(transition.after)}
return execute
async def _create(repository: TaskRepository, task: Task) -> dict[str, Any]:
node, vmid = str(task.payload["node"]), int(task.payload["vmid"])
config = dict(task.payload.get("config", {}))
resource_id = uuid.uuid4()
state = {"status": "stopped", **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, 'qemu', $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 virtual_machines(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),
)
await repository.append_log(task.id, f"VM {vmid} created")
return {"vmid": vmid, "status": "stopped"}
async def _update(repository: TaskRepository, task: Task, resource_id: uuid.UUID) -> dict[str, Any]:
changes = dict(task.payload.get("changes", {}))
delete_keys = tuple(str(task.payload.get("delete", "")).split(","))
async with repository.pool.acquire() as connection:
async with connection.transaction():
row = await connection.fetchrow(
"""SELECT r.state, v.config FROM resources r
JOIN virtual_machines v ON v.resource_id=r.id WHERE r.id=$1""",
resource_id,
)
if row is None:
raise ValueError("resource disappeared")
state = _object(row["state"])
config = _object(row["config"])
config.update(changes)
for key in delete_keys:
if key:
config.pop(key, None)
state.pop(key, None)
state.update(changes)
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 virtual_machines SET config=$2::jsonb WHERE resource_id=$1",
resource_id,
json.dumps(config, sort_keys=True),
)
await repository.append_log(task.id, "VM configuration updated")
return {"updated": sorted(changes), "deleted": sorted(key for key in delete_keys if key)}
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, "VM 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, v.config FROM resources r
JOIN virtual_machines v ON v.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"]),
"vmstate": bool(task.payload.get("vmstate", False)),
}
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"]))
if bool(task.payload.get("start", False)):
state["status"] = "running"
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 virtual_machines 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, v.config FROM resources r
JOIN virtual_machines v ON v.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["name"] = 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,'qemu',$4,$5::jsonb,'{}'::jsonb)""",
target_id,
target["id"],
target["cluster_id"],
str(vmid),
json.dumps(state),
)
await connection.execute(
"""INSERT INTO virtual_machines(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"VM 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)}
async def _move_disk(
repository: TaskRepository, task: Task, resource_id: uuid.UUID
) -> dict[str, Any]:
disk = str(task.payload["disk"])
target_disk = str(task.payload["target_disk"])
storage = str(task.payload["storage"])
async with repository.pool.acquire() as connection:
async with connection.transaction():
row = await connection.fetchrow(
"SELECT config FROM virtual_machines WHERE resource_id=$1", resource_id
)
if row is None:
raise ValueError("resource disappeared")
config = _object(row["config"])
if disk not in config:
raise ValueError("disk disappeared")
original = str(config[disk])
suffix = original.split(":", 1)[1] if ":" in original else original
config[target_disk] = f"{storage}:{suffix}"
if bool(task.payload.get("delete", True)) and target_disk != disk:
config.pop(disk, None)
await connection.execute(
"UPDATE virtual_machines SET config=$2::jsonb WHERE resource_id=$1",
resource_id,
json.dumps(config, sort_keys=True),
)
await connection.execute(
"""UPDATE resources SET state=state || $2::jsonb, version=version+1,
updated_at=now() WHERE id=$1""",
resource_id,
json.dumps({target_disk: config[target_disk]}, sort_keys=True),
)
await connection.execute(
"""UPDATE vm_disks SET device=$2,storage_id=$3
WHERE resource_id=$1 AND device=$4""",
resource_id,
target_disk,
storage,
disk,
)
await repository.append_log(task.id, f"disk {disk} moved to {storage}")
return {"disk": target_disk, "storage": storage}
def _object(value: object) -> dict[str, Any]:
return json.loads(value) if isinstance(value, str) else dict(cast(Mapping[str, Any], value))
+197
View File
@@ -0,0 +1,197 @@
"""PostgreSQL repository for durable leased tasks."""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
from typing import Any
import asyncpg # type: ignore[import-untyped] # noqa: F401
from asyncpg import Pool, Record
from app.db.primitives import ConflictError, require_affected, transaction
@dataclass(frozen=True, slots=True)
class Task:
id: uuid.UUID
upid: str
task_type: str
status: str
payload: dict[str, Any]
progress: int
cancel_requested: bool
attempt: int
def _task(row: Record) -> Task:
return Task(
id=row["id"],
upid=str(row["upid"]),
task_type=str(row["task_type"]),
status=str(row["status"]),
payload=json.loads(row["payload"])
if isinstance(row["payload"], str)
else dict(row["payload"]),
progress=int(row["progress"]),
cancel_requested=bool(row["cancel_requested"]),
attempt=int(row["attempt"]),
)
@dataclass(frozen=True, slots=True)
class TaskRepository:
pool: Pool
async def create(
self,
*,
upid: str,
task_type: str,
payload: dict[str, Any],
resource_key: str | None = None,
idempotency_key: str | None = None,
) -> Task:
task_id = uuid.uuid4()
async with transaction(self.pool) as connection:
if idempotency_key is not None:
existing = await connection.fetchrow(
"SELECT * FROM tasks WHERE idempotency_key=$1", idempotency_key
)
if existing is not None:
return _task(existing)
row = await connection.fetchrow(
"""INSERT INTO tasks(id, upid, task_type, status, payload, idempotency_key)
VALUES($1,$2,$3,'queued',$4::jsonb,$5) RETURNING *""",
task_id,
upid,
task_type,
json.dumps(payload),
idempotency_key,
)
if resource_key is not None:
try:
await connection.execute(
"INSERT INTO resource_locks(resource_key, task_id) VALUES($1,$2)",
resource_key,
task_id,
)
except Exception as error:
raise ConflictError(f"resource is locked: {resource_key}") from error
await connection.execute(
"INSERT INTO task_events(task_id, kind) VALUES($1,'created')", task_id
)
if row is None:
raise RuntimeError("task insert returned no row")
return _task(row)
async def claim(self, worker_id: str, lease_seconds: float) -> Task | None:
async with transaction(self.pool) as connection:
row = await connection.fetchrow(
"""WITH candidate AS (
SELECT id FROM tasks
WHERE status='queued' OR (status='running' AND lease_expires_at < now())
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1
) UPDATE tasks SET status='running', worker_id=$1,
lease_expires_at=now() + $2 * interval '1 second', attempt=attempt+1,
updated_at=now()
WHERE id=(SELECT id FROM candidate) RETURNING *""",
worker_id,
lease_seconds,
)
if row is None:
return None
await connection.execute(
"INSERT INTO task_events(task_id, kind, data) VALUES($1,'claimed',$2::jsonb)",
row["id"],
json.dumps({"worker": worker_id}),
)
return _task(row)
async def heartbeat(self, task_id: uuid.UUID, worker_id: str, lease_seconds: float) -> None:
status = await self.pool.execute(
"""UPDATE tasks SET lease_expires_at=now()+$3*interval '1 second', updated_at=now()
WHERE id=$1 AND worker_id=$2 AND status='running'""",
task_id,
worker_id,
lease_seconds,
)
require_affected(status)
async def progress(self, task_id: uuid.UUID, worker_id: str, value: int) -> None:
status = await self.pool.execute(
"""UPDATE tasks SET progress=$3, updated_at=now()
WHERE id=$1 AND worker_id=$2 AND status='running'""",
task_id,
worker_id,
value,
)
require_affected(status)
async def append_log(self, task_id: uuid.UUID, message: str) -> None:
await self.pool.execute(
"INSERT INTO task_logs(task_id, message) VALUES($1,$2)", task_id, message
)
async def request_cancel(self, task_id: uuid.UUID) -> None:
status = await self.pool.execute(
"""UPDATE tasks SET cancel_requested=true, updated_at=now()
WHERE id=$1 AND status IN ('queued','running')""",
task_id,
)
require_affected(status)
async def finish(
self,
task_id: uuid.UUID,
worker_id: str,
*,
status: str,
result: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
if status not in {"success", "error", "cancelled"}:
raise ValueError("invalid terminal task status")
async with transaction(self.pool) as connection:
command = await connection.execute(
"""UPDATE tasks SET status=$3, result=$4::jsonb, error=$5,
progress=CASE WHEN $3='success' THEN 100 ELSE progress END,
lease_expires_at=NULL, updated_at=now()
WHERE id=$1 AND worker_id=$2 AND status='running'""",
task_id,
worker_id,
status,
json.dumps(result) if result is not None else None,
error,
)
require_affected(command)
await connection.execute("DELETE FROM resource_locks WHERE task_id=$1", task_id)
await connection.execute(
"INSERT INTO task_events(task_id, kind, data) VALUES($1,$2,$3::jsonb)",
task_id,
status,
json.dumps({"error": error} if error else {}),
)
async def get(self, task_id: uuid.UUID) -> Task | None:
row = await self.pool.fetchrow("SELECT * FROM tasks WHERE id=$1", task_id)
return _task(row) if row is not None else None
async def get_by_upid(self, upid: str) -> Task | None:
row = await self.pool.fetchrow("SELECT * FROM tasks WHERE upid=$1", upid)
return _task(row) if row is not None else None
async def list_for_node(self, node: str) -> tuple[Task, ...]:
rows = await self.pool.fetch(
"""SELECT * FROM tasks WHERE payload->>'node'=$1
ORDER BY created_at DESC LIMIT 1000""",
node,
)
return tuple(_task(row) for row in rows)
async def logs(self, task_id: uuid.UUID) -> tuple[str, ...]:
rows = await self.pool.fetch(
"SELECT message FROM task_logs WHERE task_id=$1 ORDER BY sequence", task_id
)
return tuple(str(row["message"]) for row in rows)
+75
View File
@@ -0,0 +1,75 @@
"""Proxmox-compatible unique process/task identifiers."""
from __future__ import annotations
import re
import secrets
import time
from dataclasses import dataclass
UPID_RE = re.compile(
r"^UPID:(?P<node>[A-Za-z0-9][A-Za-z0-9_-]*):"
r"(?P<pid>[0-9A-Fa-f]{8}):(?P<pstart>[0-9A-Fa-f]{8}):"
r"(?P<start>[0-9A-Fa-f]{8}):(?P<type>[A-Za-z0-9_-]+):"
r"(?P<task_id>[^:]*):(?P<user>[^:]+):$"
)
@dataclass(frozen=True, slots=True)
class Upid:
node: str
pid: int
process_start: int
start_time: int
task_type: str
task_id: str
user: str
def __post_init__(self) -> None:
for name, value in (
("pid", self.pid),
("process_start", self.process_start),
("start_time", self.start_time),
):
if not 0 <= value <= 0xFFFFFFFF:
raise ValueError(f"{name} is outside the 32-bit UPID range")
if not self.node or ":" in self.node or not self.task_type or ":" in self.task_type:
raise ValueError("invalid UPID node or task type")
if ":" in self.task_id or not self.user or ":" in self.user:
raise ValueError("invalid UPID task id or user")
def __str__(self) -> str:
return (
f"UPID:{self.node}:{self.pid:08X}:{self.process_start:08X}:"
f"{self.start_time:08X}:{self.task_type}:{self.task_id}:{self.user}:"
)
@classmethod
def parse(cls, value: str) -> Upid:
match = UPID_RE.fullmatch(value)
if match is None:
raise ValueError("invalid UPID")
values = match.groupdict()
return cls(
node=values["node"],
pid=int(values["pid"], 16),
process_start=int(values["pstart"], 16),
start_time=int(values["start"], 16),
task_type=values["type"],
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,
)
+99
View File
@@ -0,0 +1,99 @@
"""Bounded durable task worker with cooperative cancellation."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from app.tasks.repository import Task, TaskRepository
TaskHandler = Callable[[Task], Awaitable[dict[str, Any] | None]]
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class TaskWorker:
repository: TaskRepository
worker_id: str
handlers: dict[str, TaskHandler]
concurrency: int = 2
lease_seconds: float = 30.0
poll_seconds: float = 0.1
_running: set[asyncio.Task[None]] = field(default_factory=set, init=False)
_stopping: asyncio.Event = field(default_factory=asyncio.Event, init=False)
async def run(self) -> None:
self._stopping.clear()
try:
while not self._stopping.is_set():
self._reap()
if len(self._running) >= self.concurrency:
await asyncio.sleep(self.poll_seconds)
continue
try:
task = await self.repository.claim(self.worker_id, self.lease_seconds)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("task claim failed; polling will retry")
await asyncio.sleep(self.poll_seconds)
continue
if task is None:
await asyncio.sleep(self.poll_seconds)
continue
execution = asyncio.create_task(self._execute(task))
self._running.add(execution)
finally:
if self._running:
await asyncio.gather(*self._running, return_exceptions=True)
self._running.clear()
def stop(self) -> None:
self._stopping.set()
def _reap(self) -> None:
self._running = {task for task in self._running if not task.done()}
async def _execute(self, task: Task) -> None:
handler = self.handlers.get(task.task_type)
if handler is None:
await self.repository.finish(
task.id, self.worker_id, status="error", error="unsupported task type"
)
return
try:
current = await self.repository.get(task.id)
if current is not None and current.cancel_requested:
await self.repository.finish(task.id, self.worker_id, status="cancelled")
return
execution: asyncio.Future[dict[str, Any] | None] = asyncio.ensure_future(handler(task))
heartbeat = asyncio.create_task(self._heartbeat(task))
try:
while not execution.done():
await asyncio.sleep(self.poll_seconds)
current = await self.repository.get(task.id)
if current is not None and current.cancel_requested:
execution.cancel()
await asyncio.gather(execution, return_exceptions=True)
await self.repository.finish(task.id, self.worker_id, status="cancelled")
return
result = await execution
finally:
heartbeat.cancel()
await asyncio.gather(heartbeat, return_exceptions=True)
await self.repository.finish(task.id, self.worker_id, status="success", result=result)
except asyncio.CancelledError:
raise
except Exception as error: # task failures are persisted, not leaked
await self.repository.finish(
task.id, self.worker_id, status="error", error=type(error).__name__
)
async def _heartbeat(self, task: Task) -> None:
interval = max(self.lease_seconds / 3, 0.01)
while True:
await asyncio.sleep(interval)
await self.repository.heartbeat(task.id, self.worker_id, self.lease_seconds)
+1
View File
@@ -0,0 +1 @@
"""Native vSphere REST + SOAP simulation surface."""
+1
View File
@@ -0,0 +1 @@
"""Versioned vSphere REST coverage catalogs for the lab console."""
+304
View File
@@ -0,0 +1,304 @@
"""Native vSphere API catalog (replaces Proxmox stub catalog in the console)."""
from __future__ import annotations
import re
from typing import Any
from app.vsphere.contracts.matrix import (
VERSIONS,
catalog_entries_for_major,
is_implemented_for_major,
load_bundle,
)
_PATH_PARAM = re.compile(r"\{([^{}/]+)\}")
_PATH_EXAMPLES: dict[str, str] = {
"vm": "vm-111",
"host": "host-11",
"datastore": "datastore-31",
"task": "task-1",
"snapshot": "snapshot-1",
"category_id": "urn:vmomi:InventoryServiceCategory:demo:GLOBAL",
"tag_id": "urn:vmomi:InventoryServiceTag:demo:GLOBAL",
"item_id": "item-demo",
"folder": "group-v23",
"datacenter": "datacenter-21",
"cluster": "domain-c21",
"resource_pool": "resgroup-22",
"permission_id": "1",
"policy": "policy-default",
}
# Common query/body fields for lab Params drawer (not a full OpenAPI schema).
_QUERY_FIELDS: dict[tuple[str, str], list[dict[str, Any]]] = {
("GET", "/api/vcenter/vm"): [
{
"name": "names",
"type": "array",
"optional": True,
"example": "app-0011",
"description": "Filter by VM name",
},
{
"name": "hosts",
"type": "array",
"optional": True,
"example": "host-11",
"description": "Filter by host",
},
{
"name": "power_states",
"type": "array",
"optional": True,
"example": "POWERED_ON",
"description": "Filter by power state",
},
],
("POST", "/api/vcenter/vm/{vm}/power"): [
{
"name": "action",
"type": "string",
"optional": False,
"example": "start",
"description": "start|stop|reset|suspend",
"enum": ["start", "stop", "reset", "suspend"],
},
],
("POST", "/api/vcenter/folder/{folder}"): [
{
"name": "action",
"type": "string",
"optional": False,
"example": "rename",
"description": "rename|move",
},
],
("POST", "/api/vcenter/host/{host}/maintenance"): [
{
"name": "action",
"type": "string",
"optional": False,
"example": "enter",
"description": "enter|exit",
},
],
}
_BODY_FIELDS: dict[tuple[str, str], list[dict[str, Any]]] = {
("POST", "/api/vcenter/vm"): [
{"name": "name", "type": "string", "optional": False, "example": "lab-vm"},
{
"name": "placement",
"type": "object",
"optional": True,
"example": '{"folder":"group-v23","host":"host-11","datastore":"datastore-31"}',
},
{"name": "cpu_count", "type": "integer", "optional": True, "example": "2"},
{"name": "memory_size_MiB", "type": "integer", "optional": True, "example": "2048"},
],
("POST", "/api/vcenter/datacenter"): [
{"name": "name", "type": "string", "optional": False, "example": "Datacenter-2"},
{"name": "folder", "type": "string", "optional": True, "example": "group-d1"},
],
("POST", "/api/vcenter/cluster"): [
{"name": "name", "type": "string", "optional": False, "example": "Cluster-2"},
{"name": "folder", "type": "string", "optional": True, "example": "group-h23"},
],
("POST", "/api/vcenter/folder"): [
{"name": "name", "type": "string", "optional": False, "example": "workloads"},
{"name": "parent", "type": "string", "optional": True, "example": "group-v23"},
{"name": "type", "type": "string", "optional": True, "example": "VIRTUAL_MACHINE"},
],
("POST", "/api/cis/tagging/category"): [
{
"name": "create_spec",
"type": "object",
"optional": False,
"example": '{"name":"env","description":"lab","cardinality":"MULTIPLE","associable_types":[]}',
},
],
("POST", "/api/cis/tagging/tag"): [
{
"name": "create_spec",
"type": "object",
"optional": False,
"example": '{"name":"prod","category_id":""}',
},
],
("POST", "/api/content/local-library"): [
{
"name": "create_spec",
"type": "object",
"optional": False,
"example": '{"name":"Templates"}',
},
],
}
def list_vsphere_majors(*, runtime_version: str | None) -> dict[str, Any]:
return {
"runtime_version": runtime_version or VERSIONS[9]["version"],
"plane": "vsphere-rest",
"majors": [
{
"major": major,
"series": meta["series"],
"latest_version": meta["version"],
"artifact_url": f"stub://vmware/vsphere-{meta['version']}/api-contract",
"bundled": True,
}
for major, meta in VERSIONS.items()
],
}
def vsphere_catalog_payload(major: int) -> dict[str, Any]:
meta = VERSIONS.get(major) or VERSIONS[9]
bundle = load_bundle(major)
entries = catalog_entries_for_major(major)
grouped: dict[str, dict[str, dict[str, Any]]] = {}
for entry in entries:
path = entry["path"]
parts = [p for p in path.split("/") if p]
tag = "/".join(parts[:3]) if len(parts) >= 3 else path
by_path = grouped.setdefault(tag, {})
path_entry = by_path.setdefault(path, {"path": path, "methods": []})
path_entry["methods"].append(
{
"verb": entry["verb"],
"name": f"{entry['verb'].lower()}_{parts[-1] if parts else 'root'}",
"description": f"{entry['status']} {entry['verb']} {path}",
"protected": True,
"implemented": entry["status"] in {"implemented", "stub"},
}
)
categories = [
{
"tag": tag,
"paths": sorted(by_path.values(), key=lambda item: item["path"]),
}
for tag, by_path in sorted(grouped.items())
]
return {
"major": major,
"series": meta["series"],
"source_version": meta["version"],
"latest_version": meta["version"],
"artifact_url": f"stub://vmware/vsphere-{meta['version']}/api-contract",
"bundled": True,
"path_count": sum(len(cat["paths"]) for cat in categories),
"method_count": len(entries),
"categories": categories,
"plane": "vsphere-rest",
"contract_kind": bundle.get("kind", "stub-openapi-matrix"),
}
def _field(
name: str,
*,
type_name: str = "string",
optional: bool = False,
example: Any = None,
description: str | None = None,
enum: list[str] | None = None,
) -> dict[str, Any]:
return {
"name": name,
"type": type_name,
"description": description,
"optional": optional,
"enum": enum or [],
"example": example if example is not None else name,
}
def _path_fields(path: str) -> list[dict[str, Any]]:
fields = []
for name in _PATH_PARAM.findall(path):
fields.append(
_field(
name,
optional=False,
example=_PATH_EXAMPLES.get(name, name),
description=f"Path parameter {{{name}}}",
)
)
return fields
def _body_example_from_fields(fields: list[dict[str, Any]]) -> dict[str, Any]:
body: dict[str, Any] = {}
for field in fields:
if field.get("optional"):
continue
example = field.get("example")
if isinstance(example, str) and example.startswith("{"):
try:
import json
body[field["name"]] = json.loads(example)
continue
except Exception:
body[field["name"]] = example
continue
body[field["name"]] = example
return body
def vsphere_method_payload(
*,
major: int,
path: str,
verb: str,
runtime_version: str | None,
) -> dict[str, Any]:
meta = VERSIONS.get(major) or VERSIONS[9]
upper = verb.upper()
path_fields = _path_fields(path)
key = (upper, path)
query_or_body = _QUERY_FIELDS.get(key, [])
body_fields = list(_BODY_FIELDS.get(key, []))
# Query-style action fields appear as body_fields in the Params UI (same editor).
for item in query_or_body:
body_fields.append(
_field(
str(item["name"]),
type_name=str(item.get("type") or "string"),
optional=bool(item.get("optional", True)),
example=item.get("example"),
description=item.get("description"),
enum=list(item.get("enum") or []),
)
)
# Generic POST with {path params} but no body schema → offer empty object note via name.
if upper in {"POST", "PATCH", "PUT"} and not body_fields and "{" not in path:
body_fields.append(
_field(
"name",
optional=True,
example="example",
description="Primary name field when required by create APIs",
)
)
resolved = path
for field in path_fields:
resolved = resolved.replace(f"{{{field['name']}}}", str(field["example"]))
return {
"major": major,
"path": path,
"verb": upper,
"name": path.strip("/").replace("/", "_"),
"description": f"{upper} {path}",
"resolved_path": resolved,
"path_fields": path_fields,
"body_fields": body_fields,
"indexed_fields": [],
"body_example": _body_example_from_fields(body_fields),
"implemented": is_implemented_for_major(upper, path, major),
"runtime_version": runtime_version or meta["version"],
"source_version": meta["version"],
}
+148
View File
@@ -0,0 +1,148 @@
"""Compatibility / Implementation-coverage payload for the lab UI."""
from __future__ import annotations
from collections import Counter
from pathlib import Path
from typing import Any
from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major
from app.vsphere.rest.coverage import catalog_entries
_EVIDENCE_ROOT = Path(__file__).resolve().parents[3] / "evidence"
def _level(count: int, total: int) -> dict[str, Any]:
total = max(total, 1)
return {"count": count, "score": round(count / total, 4)}
def vsphere_compatibility_payload(
major: int,
*,
runtime_version: str | None = None,
) -> dict[str, Any]:
"""Shape expected by lab UI ``updateCatalogCoverage`` / compatibility help panel."""
meta = VERSIONS.get(major) or VERSIONS[9]
universe = catalog_entries()
active = catalog_entries_for_major(major)
declared = len(universe)
implemented = len(active)
unsupported = max(declared - implemented, 0)
by_verb = Counter(e["verb"] for e in active)
universe_by_verb = Counter(e["verb"] for e in universe)
# Surface matrix treats every registered route as exercised for the active floor.
observed = implemented
verified = implemented
dimensions = {
"route_method": _level(implemented, declared),
"get": _level(by_verb.get("GET", 0), max(universe_by_verb.get("GET", 0), 1)),
"post": _level(by_verb.get("POST", 0), max(universe_by_verb.get("POST", 0), 1)),
"patch": _level(by_verb.get("PATCH", 0), max(universe_by_verb.get("PATCH", 0), 1)),
"delete": _level(by_verb.get("DELETE", 0), max(universe_by_verb.get("DELETE", 0), 1)),
"auth_session": _level(
sum(1 for e in active if e["path"] in {"/api/session", "/rest/com/vmware/cis/session"}),
6,
),
"inventory": _level(
sum(1 for e in active if "/api/vcenter/" in e["path"]),
max(sum(1 for e in universe if "/api/vcenter/" in e["path"]), 1),
),
"legacy_rest": _level(
sum(1 for e in active if e["path"].startswith("/rest/")),
max(sum(1 for e in universe if e["path"].startswith("/rest/")), 1),
),
}
evidence_path = _EVIDENCE_ROOT / f"vsphere-{meta['version']}.json"
evidence_summary: dict[str, Any] = {}
if evidence_path.is_file():
try:
import json
evidence_summary = (
json.loads(evidence_path.read_text(encoding="utf-8")).get("summary") or {}
)
except Exception:
evidence_summary = {}
active_keys = {(a["verb"], a["path"]) for a in active}
gated_entries = [
f"{e['verb']} {e['path']}" for e in universe if (e["verb"], e["path"]) not in active_keys
]
return {
"major": major,
"series": meta["series"],
"source_version": meta["version"],
"catalog_version": meta["version"],
"runtime_version": runtime_version or meta["version"],
"plane": "vsphere-rest",
"evidence_scope": "vsphere-registry",
"total_declared": declared,
"levels": {
"declared": _level(declared, declared),
# Prefer "gated" in the help UI; keep schema_only as an alias for older clients.
"gated": _level(unsupported, declared),
"schema_only": _level(unsupported, declared),
"implemented": _level(implemented, declared),
"observed": _level(observed, declared),
"verified": _level(verified, declared),
},
"dimensions": dimensions,
"classifications": {
"available": [f"{e['verb']} {e['path']}" for e in active],
"fully_compatible": [f"{e['verb']} {e['path']}" for e in active],
"partially_compatible": [],
"incompatible": [],
"regressions": [],
"unsupported": gated_entries,
"gated_501": gated_entries,
},
"summary": {
"methods": declared,
"implemented": implemented,
"unsupported_in_version": unsupported,
"coverage": round(implemented / max(declared, 1), 4),
"by_verb": dict(sorted(by_verb.items())),
"universe_by_verb": dict(sorted(universe_by_verb.items())),
**{k: v for k, v in evidence_summary.items() if k.startswith("probed")},
},
"entries": active,
}
def evidence_ledger(major: int) -> dict[str, Any]:
"""Compact on-disk ledger written by ``scripts/write_vsphere_evidence.py``."""
from datetime import UTC, datetime
payload = vsphere_compatibility_payload(major)
meta = VERSIONS[major]
return {
"product": "vmware-api-simulator",
"api_version": meta["version"],
"major": major,
"series": meta["series"],
"plane": "vsphere-rest",
"generated_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"notes": (
"Ledger derived from app/vsphere/rest/coverage.py + PATH_FLOOR. "
"implemented_methods = available at this major; "
"universe_methods = full simulator registry."
),
"summary": {
"implemented_methods": payload["summary"]["implemented"],
"universe_methods": payload["summary"]["methods"],
"unsupported_in_version": payload["summary"]["unsupported_in_version"],
"coverage": payload["summary"]["coverage"],
"by_verb": payload["summary"]["by_verb"],
"status": "partial-clone"
if payload["summary"]["coverage"] < 1
else "registry-complete",
},
"levels": payload["levels"],
"dimensions": payload["dimensions"],
}

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