Align sized cluster seeds and GET dumps with PVE wire shapes; restyle DATA panel.

- Scale small/large/big seeds (3×50 / 10×1000 / 20×2000) with proportional
  backups, snapshots, HA, replication, Ceph capacity, and OSD totals
  (10 / 100 / 500) plus matching node disks and crush/pg metadata
- Enrich handler responses for apt, certificates, qemu/lxc status, storage,
  SDN, metrics export, and related cluster/node dumps
- Flatten nested body_example fields into PARAMS and sync the request body
  via dotted paths (oVirt-style)
- Restyle DATA controls as size cards with full-width Reset to minimal /
  Refresh stats; unload reloads the minimal cluster
This commit is contained in:
Sergey Antropoff
2026-07-18 08:46:11 +03:00
parent 48df10b17e
commit 0773f721ea
77 changed files with 4870 additions and 855 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ hypothesis/
*.sublime-project *.sublime-project
*.sublime-workspace *.sublime-workspace
# Docker / local runtime # Docker / local runtime (`make up-local` writes port remaps here)
*.log *.log
docker-compose.override.yml docker-compose.override.yml
+38 -4
View File
@@ -13,7 +13,7 @@ PUSH_LATEST ?= 1
COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml
HELM_CHART ?= ./helm/proxmox-api-simulator HELM_CHART ?= ./helm/proxmox-api-simulator
.PHONY: help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up down restart logs docker-build docker-up docker-down docker-logs docker-restart db-up db-down db-migrate db-reset api-import api-diff seed clean ci ci-all shell release release-build release-up release-down release-seed helm-deps helm-template helm-lint pulumi-tests .PHONY: help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up up-local down down-local 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 helm-lint pulumi-tests push
help: ## Show available commands help: ## Show available commands
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -75,9 +75,17 @@ up: ## Start PostgreSQL and simulator (plain HTTP :8006)
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --wait $(COMPOSE) up -d --build --wait
up-local: ## Start locally; remap busy ports via gitignored docker-compose.override.yml
@COMPOSE="$(COMPOSE)" ./scripts/up-local.sh
down: ## Stop local services down: ## Stop local services
$(COMPOSE) down $(COMPOSE) down
down-local: ## Stop local stack and remove gitignored docker-compose.override.yml
@$(COMPOSE) down
@rm -f docker-compose.override.yml
@echo "Stopped local stack and removed docker-compose.override.yml"
restart: ## Rebuild and restart the stack restart: ## Rebuild and restart the stack
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --force-recreate --wait $(COMPOSE) up -d --build --force-recreate --wait
@@ -127,7 +135,11 @@ api-diff: ## Compare API snapshots
seed: ## Seed simulation data seed: ## Seed simulation data
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
SEED_PROFILE="$${PROFILE:-small}" $(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.simulation.seed_cli $(COMPOSE) run --rm \
-e SEED_PROFILE="$${PROFILE:-small}" \
-e SEED_LARGE_NODES="$${SEED_LARGE_NODES:-10}" \
-e SEED_LARGE_RESOURCES="$${SEED_LARGE_RESOURCES:-1000}" \
--entrypoint python $(SERVICE_SIM) -m app.simulation.seed_cli
shell: ## Open an interactive shell in the development container shell: ## Open an interactive shell in the development container
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash $(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash
@@ -175,8 +187,12 @@ release-down: ## Stop the published Hub stack
$(COMPOSE_RELEASE) down $(COMPOSE_RELEASE) down
release-seed: ## Seed the published Hub stack (PROFILE=small by default) release-seed: ## Seed the published Hub stack (PROFILE=small by default)
SEED_PROFILE="$${PROFILE:-small}" IMAGE_TAG="$${IMAGE_TAG:-$(VERSION)}" DOCKER_IMAGE="$(DOCKER_IMAGE)" \ IMAGE_TAG="$${IMAGE_TAG:-$(VERSION)}" DOCKER_IMAGE="$(DOCKER_IMAGE)" \
$(COMPOSE_RELEASE) run --rm --entrypoint python simulator -m app.simulation.seed_cli $(COMPOSE_RELEASE) run --rm \
-e SEED_PROFILE="$${PROFILE:-small}" \
-e SEED_LARGE_NODES="$${SEED_LARGE_NODES:-10}" \
-e SEED_LARGE_RESOURCES="$${SEED_LARGE_RESOURCES:-1000}" \
--entrypoint python simulator -m app.simulation.seed_cli
helm-deps: ## No-op placeholder (chart has no OCI dependencies) helm-deps: ## No-op placeholder (chart has no OCI dependencies)
@echo "Chart $(HELM_CHART) vendors PostgreSQL templates; no helm dependency update required." @echo "Chart $(HELM_CHART) vendors PostgreSQL templates; no helm dependency update required."
@@ -196,3 +212,21 @@ helm-template: ## Render Helm manifests locally (requires helm)
pulumi-tests: ## Full Pulumi suite (surface majors 69 + lifecycle, HTML report) pulumi-tests: ## Full Pulumi suite (surface majors 69 + lifecycle, HTML report)
$(MAKE) -C pulumi-tests up $(MAKE) -C pulumi-tests up
$(MAKE) -C pulumi-tests test $(MAKE) -C pulumi-tests test
push: ## git add ., prompt for commit message, push origin (GitHub + antropoff.ru)
@set -e; \
git add .; \
echo "=== staged ==="; \
git status --short; \
echo; \
if git diff --cached --quiet; then \
echo "Nothing to commit — pushing current branch."; \
else \
echo "Enter commit message, then Ctrl-D:"; \
msg=$$(cat </dev/tty); \
if [ -z "$$msg" ]; then echo "Empty commit message, aborting." >&2; exit 1; fi; \
git commit -m "$$msg"; \
fi; \
echo "Pushing to both remotes:"; \
git remote get-url --push --all origin | sed 's/^/ - /'; \
git push origin HEAD
+2 -2
View File
@@ -113,9 +113,9 @@ Interactive console with light/dark themes, endpoint catalog for PVE 69,
runtime contract hot-swap, and a UPID task monitor. More detail: runtime contract hot-swap, and a UPID task monitor. More detail:
[Web UI](docs/web-ui.md). [Web UI](docs/web-ui.md).
![Web UI light theme](docs/images/web-ui-light.png) ![Web UI main](docs/images/web-ui-main.png)
![Web UI dark theme](docs/images/web-ui-dark.png) ![Web UI API catalog](docs/images/web-ui-api-catalog.png)
## Documentation ## Documentation
+2 -2
View File
@@ -113,9 +113,9 @@ curl -sS -X POST -d 'username=root@pam&password=secret' \
горячей сменой runtime-контракта и монитором задач UPID. Подробнее: горячей сменой runtime-контракта и монитором задач UPID. Подробнее:
[Web UI](docs/ru/web-ui.md). [Web UI](docs/ru/web-ui.md).
![Web UI, светлая тема](docs/images/web-ui-light.png) ![Web UI, главный экран](docs/images/web-ui-main.png)
![Web UI, тёмная тема](docs/images/web-ui-dark.png) ![Web UI, каталог API](docs/images/web-ui-api-catalog.png)
## Документация ## Документация
+177 -5
View File
@@ -1,7 +1,15 @@
"""Generate example values from Proxmox contract schemas.""" """Generate example values from Proxmox contract schemas.
Examples follow the official API-viewer dialect: named ``format`` tokens,
property-string ``format`` objects (``key=value,key2=value2`` / bare
``default_key``), and common path-parameter placeholders.
"""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from app.contracts.model import Schema from app.contracts.model import Schema
_PATH_PARAM_EXAMPLES: dict[str, object] = { _PATH_PARAM_EXAMPLES: dict[str, object] = {
@@ -22,6 +30,53 @@ _PATH_PARAM_EXAMPLES: dict[str, object] = {
"key": "cpu", "key": "cpu",
"digest": "00000000", "digest": "00000000",
"name": "example", "name": "example",
"clustername": "example",
}
_FORMAT_EXAMPLES: dict[str, object] = {
"pve-node": "pve01",
"pve-node-list": "pve01,pve02",
"pve-vmid": 100,
"pve-vmid-list": "100,101",
"pve-storage-id": "local",
"pve-storage-server": "192.168.0.10",
"pve-poolid": "testpool",
"pve-userid": "root@pam",
"pve-userid-list": "root@pam,admin@pve",
"pve-realm": "pam",
"pve-groupid": "admins",
"pve-groupid-list": "admins,ops",
"pve-roleid": "Administrator",
"pve-roleid-list": "Administrator,PVEAdmin",
"pve-tokenid": "automation",
"pve-tokenid-list": "automation",
"pve-priv-list": "VM.Allocate,VM.Config.Options",
"pve-iface": "net0",
"pve-iface-list": "net0,net1",
"pve-configid": "default",
"pve-configid-list": "default",
"pve-sdn-vnet-id": "vnet0",
"pve-replication-job-id": "100-0",
"pve-ha-resource-or-vm-id": "vm:100",
"pve-ha-group-node-list": "pve01:1,pve02:1",
"pve-day-of-week-list": "mon,tue,wed",
"pve-fw-addr-spec": "192.168.0.0/24",
"pve-fw-dport-spec": "22",
"pve-fw-sport-spec": "1024:65535",
"pve-fw-protocol-spec": "tcp",
"pve-fw-icmp-type-spec": "echo-request",
"address": "192.168.0.1",
"ip": "192.168.0.1",
"IPorCIDR": "192.168.0.0/24",
"IPorCIDRorAlias": "192.168.0.0/24",
"dns-name": "example.local",
"email-opt": "admin@example.local",
"email-list": "admin@example.local",
"email-or-username-list": "admin@example.local",
"ldap-simple-attr": "uid",
"ldap-simple-attr-list": "posixGroup,groupOfNames",
"string-alist": "/var/lib/vz",
"pve-tfa-secret": "ABCDEFGHIJKLMNOP",
} }
@@ -31,6 +86,14 @@ def path_param_example(name: str) -> object | None:
return _PATH_PARAM_EXAMPLES.get(name) return _PATH_PARAM_EXAMPLES.get(name)
def wire_param_name(name: str, *, index: int = 0) -> str:
"""Map contract ``foo[n]`` names to a concrete wire key (``foo0``)."""
if "[n]" in name:
return name.replace("[n]", str(index))
return name
def schema_example(schema: Schema, *, name: str | None = None) -> object: def schema_example(schema: Schema, *, name: str | None = None) -> object:
"""Build a representative example value for a contract schema.""" """Build a representative example value for a contract schema."""
@@ -47,6 +110,19 @@ def schema_example(schema: Schema, *, name: str | None = None) -> object:
hinted = path_param_example(indexed.rstrip("0123456789")) hinted = path_param_example(indexed.rstrip("0123456789"))
if hinted is not None: if hinted is not None:
return hinted return hinted
base = indexed.rstrip("0123456789")
if base in _PATH_PARAM_EXAMPLES:
return _PATH_PARAM_EXAMPLES[base]
if isinstance(schema.format, Mapping):
return property_string_example(schema.format)
if isinstance(schema.format, str):
formatted = _named_format_example(schema.format, name=name)
if formatted is not None:
return formatted
if schema.format == "email":
return "user@example.com"
if schema.format == "uri":
return "https://example.com"
if schema.type == "array": if schema.type == "array":
if schema.items is not None: if schema.items is not None:
return [schema_example(schema.items)] return [schema_example(schema.items)]
@@ -68,9 +144,105 @@ def schema_example(schema: Schema, *, name: str | None = None) -> object:
return float(schema.minimum) return float(schema.minimum)
return 1.0 return 1.0
if schema.type == "string" or schema.type is None: 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 "example"
return None return None
def property_string_example(fmt: Mapping[str, Any]) -> str:
"""Render a Proxmox property-string example from a ``format`` object.
Mirrors the API viewer dialect: a ``default_key`` may appear as a bare
value; other required keys use ``key=value``; optional keys are omitted
from the minimal example (they appear in ``typetext`` as ``[,key=…]``).
"""
bare: str | None = None
keyed: list[str] = []
for key, raw in sorted(fmt.items()):
if not isinstance(raw, Mapping):
continue
value = _format_key_example(key, raw)
rendered = _stringify_property_value(value)
if raw.get("default_key"):
bare = rendered
continue
if _is_optional_flag(raw.get("optional")):
continue
keyed.append(f"{key}={rendered}")
if bare is not None and not keyed:
return bare
if bare is not None:
return ",".join([bare, *keyed])
if keyed:
return ",".join(keyed)
# All keys optional — include defaults / default_key style extras for a
# usable stub that still matches typetext shape.
fallback: list[str] = []
for key, raw in sorted(fmt.items()):
if not isinstance(raw, Mapping):
continue
value = _format_key_example(key, raw)
rendered = _stringify_property_value(value)
if raw.get("default_key"):
return rendered
fallback.append(f"{key}={rendered}")
return ",".join(fallback) if fallback else "example"
def _named_format_example(fmt: str, *, name: str | None) -> object | None:
if fmt in _FORMAT_EXAMPLES:
return _FORMAT_EXAMPLES[fmt]
if fmt.endswith("-list") and fmt[: -len("-list")] in _FORMAT_EXAMPLES:
base = _FORMAT_EXAMPLES[fmt[: -len("-list")]]
return str(base)
if name and name in _FORMAT_EXAMPLES:
return _FORMAT_EXAMPLES[name]
return None
def _format_key_example(key: str, raw: Mapping[str, Any]) -> object:
if "default" in raw and raw["default"] is not None:
return raw["default"]
enum = raw.get("enum")
if isinstance(enum, (list, tuple)) and enum:
return enum[0]
nested_format = raw.get("format")
if isinstance(nested_format, Mapping):
return property_string_example(nested_format)
if isinstance(nested_format, str):
named = _named_format_example(nested_format, name=key)
if named is not None:
return named
if nested_format == "email":
return "user@example.com"
if nested_format == "uri":
return "https://example.com"
hinted = path_param_example(key)
if hinted is not None:
return hinted
typ = raw.get("type")
if typ == "boolean":
return 1
if typ == "integer":
minimum = raw.get("minimum")
return int(minimum) if isinstance(minimum, (int, float)) else 1
if typ == "number":
minimum = raw.get("minimum")
return float(minimum) if isinstance(minimum, (int, float)) else 1.0
pattern = raw.get("pattern")
if isinstance(pattern, str) and "second|minute|hour|day" in pattern:
return "1/second"
return "example"
def _stringify_property_value(value: object) -> str:
if isinstance(value, bool):
return "1" if value else "0"
return str(value)
def _is_optional_flag(value: object) -> bool:
if value is True or value == 1 or value == "1":
return True
return False
+79 -8
View File
@@ -125,7 +125,7 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
return [ return [
{ {
"userid": str(row["name"]), "userid": str(row["name"]),
"enable": 1 if row["enabled"] else 0, "enable": bool(row["enabled"]),
"realm-type": str(row["realm_kind"]), "realm-type": str(row["realm_kind"]),
} }
for row in rows for row in rows
@@ -168,10 +168,25 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
) )
if row is None: if row is None:
raise ApiError(404, "user does not exist") raise ApiError(404, "user does not exist")
groups = await database(request).pool.fetch(
"""SELECT g.group_id FROM identity_group_members m
JOIN identity_groups g ON g.id = m.group_id
JOIN principals p ON p.id = m.principal_id
WHERE p.name=$1 ORDER BY g.group_id""",
userid,
)
tokens = await database(request).pool.fetch(
"""SELECT t.token_id FROM api_tokens t
JOIN principals p ON p.id = t.principal_id
WHERE p.name=$1 ORDER BY t.token_id""",
userid,
)
return { return {
"userid": str(row["name"]), "userid": str(row["name"]),
"enable": 1 if row["enabled"] else 0, "enable": bool(row["enabled"]),
"realm-type": str(row["realm_kind"]), "realm-type": str(row["realm_kind"]),
"groups": [str(item["group_id"]) for item in groups],
"tokens": [{"tokenid": str(item["token_id"]), "privsep": 1} for item in tokens],
} }
async def user_update(request: Request, inputs: dict[str, Any]) -> None: async def user_update(request: Request, inputs: dict[str, Any]) -> None:
@@ -397,10 +412,18 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
path = str(payload["path"]) path = str(payload["path"])
roleid = str(payload["roles"]) roleid = str(payload["roles"])
propagate = bool(int(payload.get("propagate", 1))) propagate = bool(int(payload.get("propagate", 1)))
delete = bool(int(payload.get("delete", 0)))
users = [item.strip() for item in str(payload.get("users", "")).split(",") if item.strip()] users = [item.strip() for item in str(payload.get("users", "")).split(",") if item.strip()]
groups = [ groups = [
item.strip() for item in str(payload.get("groups", "")).split(",") if item.strip() item.strip() for item in str(payload.get("groups", "")).split(",") if item.strip()
] ]
tokens = [
item.strip() for item in str(payload.get("tokens", "")).split(",") if item.strip()
]
await database(request).pool.execute(
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""",
roleid,
)
for userid in users: for userid in users:
principal_id = await database(request).pool.fetchval( principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1", "SELECT id FROM principals WHERE name=$1",
@@ -408,10 +431,15 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
) )
if principal_id is None: if principal_id is None:
raise ApiError(404, f"user {userid} does not exist") raise ApiError(404, f"user {userid} does not exist")
if delete:
await database(request).pool.execute( await database(request).pool.execute(
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""", """DELETE FROM acl_entries
WHERE principal_id=$1 AND role_name=$2 AND path=$3""",
principal_id,
roleid, roleid,
path,
) )
continue
await database(request).pool.execute( await database(request).pool.execute(
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate) """INSERT INTO acl_entries(principal_id, role_name, path, propagate)
VALUES($1, $2, $3, $4) VALUES($1, $2, $3, $4)
@@ -429,10 +457,15 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
) )
if group_id is None: if group_id is None:
raise ApiError(404, f"group {groupid} does not exist") raise ApiError(404, f"group {groupid} does not exist")
if delete:
await database(request).pool.execute( await database(request).pool.execute(
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""", """DELETE FROM group_acl_entries
WHERE group_id=$1 AND role_name=$2 AND path=$3""",
group_id,
roleid, roleid,
path,
) )
continue
await database(request).pool.execute( await database(request).pool.execute(
"""INSERT INTO group_acl_entries(group_id, role_name, path, propagate) """INSERT INTO group_acl_entries(group_id, role_name, path, propagate)
VALUES($1, $2, $3, $4) VALUES($1, $2, $3, $4)
@@ -443,6 +476,46 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
path, path,
propagate, propagate,
) )
for token_ref in tokens:
# Wire form: userid!tokenid
userid, _, tokenid = token_ref.partition("!")
if not userid or not tokenid:
raise ApiError(400, f"invalid token ACL subject '{token_ref}'")
token_row = await database(request).pool.fetchrow(
"""SELECT t.id 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 token_row is None:
raise ApiError(404, f"token {token_ref} does not exist")
# Persist as principal ACL under the owning user with token scope in path metadata.
# Keep durable by tagging path; GET ACL still lists principal entries.
scoped_path = f"{path}#token:{tokenid}" if path != "/" else f"/#token:{tokenid}"
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if delete:
await database(request).pool.execute(
"""DELETE FROM acl_entries
WHERE principal_id=$1 AND role_name=$2 AND path=$3""",
principal_id,
roleid,
scoped_path,
)
else:
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,
scoped_path,
propagate,
)
async def token_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def token_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
userid = str(values(inputs)["userid"]) userid = str(values(inputs)["userid"])
@@ -562,10 +635,8 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
) )
if row is None: if row is None:
raise ApiError(404, "role does not exist") raise ApiError(404, "role does not exist")
return { # Contract returns privilege→boolean map (additionalProperties: 0 on named privs).
"roleid": str(row["name"]), return {str(priv): 1 for priv in row["privileges"]}
"privs": ",".join(str(item) for item in row["privileges"]),
}
async def role_create(request: Request, inputs: dict[str, Any]) -> None: async def role_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs) payload = values(inputs)
+14 -3
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json
from typing import Any from typing import Any
from fastapi import Request from fastapi import Request
@@ -35,7 +37,7 @@ def _default_directory(acme: dict[str, Any]) -> str:
for item in directories: for item in directories:
if isinstance(item, dict) and item.get("url"): if isinstance(item, dict) and item.get("url"):
return str(item["url"]) return str(item["url"])
return "" return "https://acme-v02.api.letsencrypt.org/directory"
def register_acme_handlers(registry: HandlerRegistry) -> None: def register_acme_handlers(registry: HandlerRegistry) -> None:
@@ -61,7 +63,7 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
for name, item in sorted(accounts.items()) for name, item in sorted(accounts.items())
] ]
async def account_create(request: Request, inputs: dict[str, Any]) -> None: async def account_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs) payload = values(inputs)
name = str(payload.get("name") or "default") name = str(payload.get("name") or "default")
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
@@ -80,6 +82,7 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
"location": f"https://acme.example.local/acct/{name}", "location": f"https://acme.example.local/acct/{name}",
} }
await save_cluster_metadata(request, metadata) await save_cluster_metadata(request, metadata)
return name
async def account_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def account_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["name"]) name = str(values(inputs)["name"])
@@ -88,8 +91,10 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
if not isinstance(account, dict): if not isinstance(account, dict):
raise ApiError(404, "ACME account does not exist") raise ApiError(404, "ACME account does not exist")
return { return {
"account": {
"name": name, "name": name,
"contact": account.get("contact"), "contact": account.get("contact"),
},
"directory": account.get("directory"), "directory": account.get("directory"),
"tos": account.get("tos_url"), "tos": account.get("tos_url"),
"location": account.get("location"), "location": account.get("location"),
@@ -123,7 +128,13 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
for plugin_id, item in sorted(plugins.items()): for plugin_id, item in sorted(plugins.items()):
if plugin_type and item.get("type") != plugin_type: if plugin_type and item.get("type") != plugin_type:
continue continue
result.append({"plugin": plugin_id, **{k: v for k, v in item.items() if k != "data"}}) entry = {"plugin": plugin_id, **{k: v for k, v in item.items() if k != "data"}}
if "digest" not in entry:
material = {k: v for k, v in entry.items() if k != "digest"}
entry["digest"] = hashlib.sha1(
json.dumps(material, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
result.append(entry)
return result return result
async def plugins_create(request: Request, inputs: dict[str, Any]) -> None: async def plugins_create(request: Request, inputs: dict[str, Any]) -> None:
+179 -129
View File
@@ -1,8 +1,8 @@
"""Cluster backup and vzdump handlers.""" """Cluster backup jobs and node vzdump handlers."""
from __future__ import annotations from __future__ import annotations
import json import secrets
from typing import Any from typing import Any
from fastapi import Request from fastapi import Request
@@ -10,149 +10,198 @@ from fastapi import Request
from app.api.errors import ApiError from app.api.errors import ApiError
from app.api.registry import HandlerRegistry from app.api.registry import HandlerRegistry
from app.db.primitives import ConflictError from app.db.primitives import ConflictError
from app.handlers.common import database, require_node, state, values from app.handlers.common import (
cluster_metadata,
database,
require_node,
save_cluster_metadata,
state,
subdirs,
values,
)
from app.tasks.repository import TaskRepository from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid from app.tasks.upid import Upid
def _backup_jobs(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
jobs = metadata.get("backup_jobs")
if not isinstance(jobs, dict):
return {}
return {
str(job_id): dict(payload) for job_id, payload in jobs.items() if isinstance(payload, dict)
}
def _job_view(job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
view = dict(payload)
view["id"] = job_id
enabled = view.get("enabled", 1)
view["enabled"] = bool(int(enabled)) if not isinstance(enabled, bool) else enabled
return view
def register_backup_handlers(registry: HandlerRegistry) -> None: def register_backup_handlers(registry: HandlerRegistry) -> None:
async def backup_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: async def backup_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch( metadata = await cluster_metadata(request)
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at, jobs = _backup_jobs(metadata)
r.external_id AS vmid, n.name AS node, s.storage_id return [_job_view(job_id, payload) for job_id, payload in sorted(jobs.items())]
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id async def backup_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
LEFT JOIN nodes n ON n.id = r.node_id job_id = str(values(inputs)["id"])
JOIN storages s ON s.resource_id = b.storage_resource_id metadata = await cluster_metadata(request)
ORDER BY b.created_at DESC LIMIT 2000""" jobs = _backup_jobs(metadata)
payload = jobs.get(job_id)
if payload is None:
raise ApiError(404, "backup job does not exist")
return _job_view(job_id, payload)
async def backup_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
job_id = str(payload.get("id") or f"backup-{secrets.token_hex(3)}")
if job_id in jobs:
raise ApiError(400, f"backup job '{job_id}' already exists")
entry = {
key: value
for key, value in payload.items()
if key not in {"delete", "digest", "node", "target"}
}
entry["id"] = job_id
entry.setdefault("enabled", 1)
entry.setdefault("storage", payload.get("storage") or "local")
jobs[job_id] = entry
metadata["backup_jobs"] = jobs
await save_cluster_metadata(request, metadata)
async def backup_update(request: Request, inputs: dict[str, Any]) -> None:
job_id = str(values(inputs)["id"])
payload = values(inputs)
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
current = jobs.get(job_id)
if current is None:
raise ApiError(404, "backup job does not exist")
updated = dict(current)
for key, value in payload.items():
if key in {"id", "delete", "digest"}:
continue
updated[key] = value
updated["id"] = job_id
jobs[job_id] = updated
metadata["backup_jobs"] = jobs
await save_cluster_metadata(request, metadata)
async def backup_delete(request: Request, inputs: dict[str, Any]) -> None:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
if job_id not in jobs:
raise ApiError(404, "backup job does not exist")
del jobs[job_id]
metadata["backup_jobs"] = jobs
await save_cluster_metadata(request, metadata)
async def backup_info(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("not-backed-up")
async def backupinfo_stub(_request: Request, _inputs: dict[str, Any]) -> str:
# PVE 6.x `/cluster/backupinfo` returns a stub string; 7+ uses `/backup-info` index.
return "Please use the 'not-backed-up' API"
async def backup_not_backed_up(
request: Request, _inputs: dict[str, Any]
) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
covered: set[str] = set()
for payload in jobs.values():
if int(payload.get("all") or 0):
covered.add("*")
for item in str(payload.get("vmid") or "").split(","):
item = item.strip()
if item:
covered.add(item)
rows = await database(request).pool.fetch(
"""SELECT r.external_id, r.kind, r.state FROM resources r
WHERE r.kind IN ('qemu', 'lxc')
ORDER BY r.external_id::integer"""
) )
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
if "*" in covered:
return result
for row in rows: for row in rows:
metadata = state(row["metadata"]) vmid = str(row["external_id"])
if vmid in covered:
continue
guest_state = state(row["state"])
result.append( result.append(
{ {
"id": str(row["id"]), "vmid": int(vmid),
"volid": str(row["volume_id"]), "type": "qemu" if row["kind"] == "qemu" else "lxc",
"size": int(row["size_bytes"]), "name": str(guest_state.get("name") or f"{row['kind']}-{vmid}"),
"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 return result
async def backup_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def backup_included_volumes(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
backup_id = str(values(inputs)["id"]) job_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow( metadata = await cluster_metadata(request)
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at, jobs = _backup_jobs(metadata)
r.external_id AS vmid, n.name AS node, s.storage_id job = jobs.get(job_id)
FROM backups b if job is None:
LEFT JOIN resources r ON r.id = b.resource_id raise ApiError(404, "backup job does not exist")
LEFT JOIN nodes n ON n.id = r.node_id vmids = [item.strip() for item in str(job.get("vmid") or "").split(",") if item.strip()]
JOIN storages s ON s.resource_id = b.storage_resource_id guest_select = """SELECT r.external_id, r.kind, r.state,
WHERE b.id::text = $1 OR b.volume_id = $1""", v.config AS qemu_config, c.config AS lxc_config
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 FROM resources r
JOIN nodes n ON n.id = r.node_id LEFT JOIN virtual_machines v ON v.resource_id = r.id
LEFT JOIN backups b ON b.resource_id = r.id LEFT JOIN containers c ON c.resource_id = r.id"""
WHERE r.kind = 'qemu' if int(job.get("all") or 0):
GROUP BY r.external_id, n.name rows = await database(request).pool.fetch(
ORDER BY r.external_id::integer f"""{guest_select}
LIMIT 5000""" WHERE r.kind IN ('qemu', 'lxc')
)
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""" ORDER BY r.external_id::integer"""
) )
return [int(row["vmid"]) for row in rows] else:
rows = await database(request).pool.fetch(
async def backup_included_volumes(request: Request, inputs: dict[str, Any]) -> list[str]: f"""{guest_select}
backup_id = str(values(inputs)["id"]) WHERE r.kind IN ('qemu', 'lxc') AND r.external_id = ANY($1::text[])
row = await database(request).pool.fetchrow( ORDER BY r.external_id::integer""",
"""SELECT b.volume_id, r.external_id AS vmid vmids,
FROM backups b LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.id::text = $1""",
backup_id,
) )
if row is None: children: list[dict[str, Any]] = []
raise ApiError(404, "backup does not exist") for row in rows:
vmid = row["vmid"] guest_state = state(row["state"])
return [f"qemu/{vmid}"] if vmid is not None else [str(row["volume_id"])] config_raw = row["qemu_config"] if row["kind"] == "qemu" else row["lxc_config"]
config = state(config_raw) if config_raw is not None else {}
volumes: list[dict[str, Any]] = []
for key, value in sorted(config.items()):
if not (
key.startswith("scsi")
or key.startswith("virtio")
or key.startswith("sata")
or key.startswith("ide")
or key in {"rootfs", "mp0", "mp1", "mp2", "mp3"}
):
continue
volumes.append(
{
"id": key,
"name": str(value).split(",", 1)[0],
"included": True,
"reason": "included by default",
}
)
children.append(
{
"id": int(row["external_id"]),
"name": str(guest_state.get("name") or f"{row['kind']}-{row['external_id']}"),
"type": "qemu" if row["kind"] == "qemu" else "lxc",
"children": volumes,
}
)
return {"children": children}
async def vzdump_defaults(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def vzdump_defaults(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
from app.handlers.nodes import load_node_ops from app.handlers.nodes import load_node_ops
@@ -193,6 +242,7 @@ def register_backup_handlers(registry: HandlerRegistry) -> None:
registry.register("/cluster/backup", "GET", backup_list) registry.register("/cluster/backup", "GET", backup_list)
registry.register("/cluster/backup", "POST", backup_create) registry.register("/cluster/backup", "POST", backup_create)
registry.register("/cluster/backup-info", "GET", backup_info) registry.register("/cluster/backup-info", "GET", backup_info)
registry.register("/cluster/backupinfo", "GET", backupinfo_stub)
registry.register("/cluster/backup-info/not-backed-up", "GET", backup_not_backed_up) 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}", "GET", backup_get)
registry.register("/cluster/backup/{id}", "PUT", backup_update) registry.register("/cluster/backup/{id}", "PUT", backup_update)
@@ -229,11 +279,11 @@ async def _schedule_vzdump(
payload={ payload={
"node": node, "node": node,
"vmids": vmids, "vmids": vmids,
"storage": str(payload.get("storage") or "nfs-backup"), "storage": str(payload.get("storage") or "local"),
"mode": str(payload.get("mode") or "snapshot"), "mode": str(payload.get("mode") or "snapshot"),
"compress": str(payload.get("compress") or "zstd"), "compress": str(payload.get("compress") or "zstd"),
}, },
resource_key=f"backup:{node}", resource_key=f"backup:{node}:{secrets.token_hex(4)}",
idempotency_key=request.headers.get("Idempotency-Key"), idempotency_key=request.headers.get("Idempotency-Key"),
) )
except ConflictError as error: except ConflictError as error:
+105 -15
View File
@@ -242,11 +242,45 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
await _save_cluster_ceph(request, ceph) await _save_cluster_ceph(request, ceph)
return _upid(node, "cephrestart") return _upid(node, "cephrestart")
def _wire_ceph_pool(name: str, item: dict[str, Any], *, index: int) -> dict[str, Any]:
pool_id = item.get("pool_id", item.get("pool"))
try:
pool_num = int(pool_id)
except (TypeError, ValueError):
pool_num = index
crush_raw = item.get("crush_rule", 0)
try:
crush_id = int(crush_raw)
crush_name = str(item.get("crush_rule_name") or "replicated_rule")
except (TypeError, ValueError):
crush_name = str(crush_raw or "replicated_rule")
crush_id = 0
application = str(item.get("application") or "rbd")
return {
"pool": pool_num,
"pool_name": str(item.get("pool_name") or name),
"size": int(item.get("size") or 3),
"min_size": int(item.get("min_size") or 2),
"pg_num": int(item.get("pg_num") or 128),
"crush_rule": crush_id,
"crush_rule_name": crush_name,
"application_metadata": item.get("application_metadata")
if isinstance(item.get("application_metadata"), dict)
else {application: {}},
"bytes_used": int(item.get("bytes_used") or 0),
"percent_used": float(item.get("percent_used") or 0.0),
"healthy": bool(item.get("healthy", True)),
}
async def pool_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def pool_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"])) await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request) ceph = await _load_cluster_ceph(request)
pools = ceph.get("pools") or {} pools = ceph.get("pools") or {}
return [dict(item) for _, item in sorted(pools.items()) if isinstance(item, dict)] return [
_wire_ceph_pool(name, item, index=index)
for index, (name, item) in enumerate(sorted(pools.items()))
if isinstance(item, dict)
]
async def pool_create(request: Request, inputs: dict[str, Any]) -> str: async def pool_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs) payload = values(inputs)
@@ -278,7 +312,10 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
pool = (ceph.get("pools") or {}).get(name) pool = (ceph.get("pools") or {}).get(name)
if not isinstance(pool, dict): if not isinstance(pool, dict):
raise ApiError(404, "pool does not exist") raise ApiError(404, "pool does not exist")
return [dict(pool)] # Reuse list wire shape for detail GETs.
names = sorted((ceph.get("pools") or {}).keys())
index = names.index(name) if name in names else 0
return [_wire_ceph_pool(name, pool, index=index)]
async def pool_update(request: Request, inputs: dict[str, Any]) -> str: async def pool_update(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs) payload = values(inputs)
@@ -482,30 +519,63 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
await _save_node_ceph(request, node, ceph) await _save_node_ceph(request, node, ceph)
return _upid(node, "cephdestroymon") return _upid(node, "cephdestroymon")
async def osd_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def osd_list(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
await require_node(request, node) await require_node(request, node)
rows = await database(request).pool.fetch( rows = await database(request).pool.fetch(
"""SELECT r.external_id, r.state """SELECT r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='ceph-osd' WHERE r.kind='ceph-osd'
ORDER BY r.external_id""", ORDER BY n.name, r.external_id"""
node,
) )
result: list[dict[str, Any]] = [] hosts: dict[str, dict[str, Any]] = {}
host_id = -2
for row in rows: for row in rows:
host_name = str(row["node"])
host = hosts.get(host_name)
if host is None:
host = {
"id": host_id,
"name": host_name,
"type": "host",
"type_id": 1,
"children": [],
}
hosts[host_name] = host
host_id -= 1
payload = state(row["state"]) payload = state(row["state"])
osd_id = payload.get("osd_id", row["external_id"]) osd_raw = payload.get("osd_id", row["external_id"])
result.append( try:
osd_id = int(osd_raw)
except (TypeError, ValueError):
digits = "".join(ch for ch in str(osd_raw) if ch.isdigit())
osd_id = int(digits) if digits else 0
host["children"].append(
{ {
"osd": int(osd_id) if str(osd_id).isdigit() else osd_id, "id": osd_id,
"name": f"osd.{osd_id}",
"type": "osd",
"type_id": 0,
"status": payload.get("status", "up"), "status": payload.get("status", "up"),
"in": 1 if payload.get("in", True) else 0, "in": 1 if payload.get("in", True) else 0,
"weight": payload.get("weight", 1.0), "weight": float(payload.get("weight", 1.0)),
"device_class": payload.get("device_class", "hdd"), "device_class": payload.get("device_class", "hdd"),
"host": host_name,
} }
) )
return result root_children = list(hosts.values())
# Prefer the requested node first in the tree for local dumps.
root_children.sort(key=lambda item: (0 if item["name"] == node else 1, item["name"]))
return {
"flags": "",
"root": {
"id": -1,
"name": "default",
"type": "root",
"type_id": 10,
"children": root_children,
},
}
async def osd_create(request: Request, inputs: dict[str, Any]) -> str: async def osd_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs) payload = values(inputs)
@@ -677,16 +747,36 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
health = ceph.get("health") health = ceph.get("health")
if not isinstance(health, dict): if not isinstance(health, dict):
health = {"status": "HEALTH_OK" if ceph.get("running") else "HEALTH_WARN"} health = {"status": "HEALTH_OK" if ceph.get("running") else "HEALTH_WARN"}
node_count = int(await database(_request).pool.fetchval("SELECT COUNT(*) FROM nodes") or 0)
return { return {
"version": {
"version": version_str, "version": version_str,
"release": "reef",
"epoch": 0,
},
"health": health, "health": health,
"osdmap": { "osdmap": {
"epoch": 1,
"num_osds": num_osds, "num_osds": num_osds,
"num_up_osds": num_up, "num_up_osds": num_up,
"num_in_osds": num_in, "num_in_osds": num_in,
"num_remapped_pgs": 0,
}, },
"pgmap": {"bytes_used": used, "bytes_total": total}, "pgmap": {
"fsmap": {"filesystems": list((ceph.get("fs") or {}).keys())}, "bytes_used": used,
"bytes_total": total,
"bytes_avail": max(total - used, 0),
"data_bytes": used,
"num_pgs": max(num_osds * 32, 32),
},
"fsmap": {
"epoch": 1,
"by_rank": [],
"filesystems": list((ceph.get("fs") or {}).keys()),
},
"monmap": {"num_mons": max(node_count, 1)},
"mgrmap": {"available": True, "num_standbys": 0},
"servicemap": {"epoch": 1, "services": {}},
} }
base = "/nodes/{node}/ceph" base = "/nodes/{node}/ceph"
+65 -12
View File
@@ -24,7 +24,43 @@ def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
jobs = metadata.get("replication", []) jobs = metadata.get("replication", [])
if not isinstance(jobs, list): if not isinstance(jobs, list):
return [] return []
return [dict(item) for item in jobs if isinstance(item, dict)] result: list[dict[str, Any]] = []
for item in jobs:
if not isinstance(item, dict):
continue
guest_raw = item.get("guest", 0)
try:
guest = int(guest_raw)
except (TypeError, ValueError):
guest = int(str(guest_raw).split(":")[-1] or 0)
jobnum_raw = item.get("jobnum")
if jobnum_raw is None:
id_text = str(item.get("id") or "0")
tail = id_text.rsplit("-", 1)[-1]
jobnum = int(tail) if tail.isdigit() else 0
else:
jobnum = int(jobnum_raw)
disable = item.get("disable")
if disable is None:
disable = 0 if int(item.get("enabled", 1) or 1) else 1
entry = {
"id": str(item.get("id") or f"{guest}-{jobnum}"),
"guest": guest,
"jobnum": jobnum,
"target": str(item.get("target") or ""),
"type": str(item.get("type") or "local"),
"disable": bool(int(disable)),
}
if item.get("schedule") is not None:
entry["schedule"] = str(item["schedule"])
if item.get("rate") is not None:
entry["rate"] = int(item["rate"])
if item.get("comment") is not None:
entry["comment"] = str(item["comment"])
if item.get("source") is not None:
entry["source"] = str(item["source"])
result.append(entry)
return result
def register_cluster_handlers(registry: HandlerRegistry) -> None: def register_cluster_handlers(registry: HandlerRegistry) -> None:
@@ -53,24 +89,41 @@ def register_cluster_handlers(registry: HandlerRegistry) -> None:
"""SELECT id, name, status FROM nodes ORDER BY name""" """SELECT id, name, status FROM nodes ORDER BY name"""
) )
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
quorate = metadata.get("quorate") quorate = bool(int(metadata.get("quorate", 1) or 1))
result: list[dict[str, Any]] = [] cluster_name = metadata.get("cluster_config")
if isinstance(cluster_name, dict):
name = str(cluster_name.get("clustername") or "pve-simulator")
version = int(cluster_name.get("version") or 2)
else:
name = "pve-simulator"
version = 2
result: list[dict[str, Any]] = [
{
"type": "cluster",
"id": "cluster",
"name": name,
"nodes": len(rows),
"quorate": quorate,
"version": version,
}
]
for index, row in enumerate(rows): for index, row in enumerate(rows):
online = str(row["status"]) == "online" online = str(row["status"]) == "online"
ops = await load_node_ops(request, str(row["name"])) ops = await load_node_ops(request, str(row["name"]))
cluster_node = ops.get("cluster_status") cluster_node = ops.get("cluster_status")
entry = dict(cluster_node) if isinstance(cluster_node, dict) else {} entry = dict(cluster_node) if isinstance(cluster_node, dict) else {}
local_raw = entry.get("local", index == 0)
node_name = str(row["name"])
result.append( result.append(
{ {
"id": str(row["id"]), "type": "node",
"name": str(row["name"]), "id": f"node/{node_name}",
"nodeid": entry.get("nodeid", index), "name": node_name,
"online": 1 if online else 0, "nodeid": int(entry.get("nodeid", index + 1)),
"local": entry.get("local", 1 if index == 0 else 0), "online": online,
"ip": entry.get("ip") or ops.get("ip") or "", "local": bool(int(local_raw)) if not isinstance(local_raw, bool) else local_raw,
"level": entry.get("level", "c"), "ip": str(entry.get("ip") or ops.get("ip") or ""),
"type": entry.get("type", "node"), "level": str(entry.get("level", "")),
"quorate": entry.get("quorate", quorate if quorate is not None else 1),
} }
) )
return result return result
+251 -43
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import secrets import secrets
from typing import Any from typing import Any
@@ -12,10 +13,12 @@ from app.api.registry import HandlerRegistry
from app.handlers.common import ( from app.handlers.common import (
cluster_metadata, cluster_metadata,
database, database,
require_value,
save_cluster_metadata, save_cluster_metadata,
subdirs,
values, values,
) )
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
def _config(metadata: dict[str, Any]) -> dict[str, Any]: def _config(metadata: dict[str, Any]) -> dict[str, Any]:
@@ -23,18 +26,131 @@ def _config(metadata: dict[str, Any]) -> dict[str, Any]:
return current if isinstance(current, dict) else {} return current if isinstance(current, dict) else {}
def _principal(request: Request) -> str:
return str(getattr(request.state, "principal", None) or "root@pam")
def _fingerprint_sha256() -> str:
return ":".join(f"{byte:02X}" for byte in secrets.token_bytes(32))
def _stable_digest(material: str) -> str:
return hashlib.sha256(material.encode("utf-8")).hexdigest()
def _node_addr(node: str, index: int, entry: dict[str, Any]) -> str:
for key in ("pve_addr", "new_node_ip", "ring0_addr"):
value = entry.get(key)
if isinstance(value, str) and value and not value.endswith(".local"):
# Prefer literal IPs; hostnames are still accepted as ring addresses.
if key != "ring0_addr" or all(part.isdigit() for part in value.split(".")):
return value
return f"10.0.0.{index}"
def _ring0_addr(node: str, index: int, entry: dict[str, Any], links: dict[str, Any]) -> str:
if entry.get("ring0_addr"):
return str(entry["ring0_addr"])
link0 = links.get("link0")
if isinstance(link0, str) and link0:
# Property string may be bare IP or address=IP[,priority=N].
if link0.startswith("address="):
return link0.removeprefix("address=").split(",", 1)[0]
return link0.split(",", 1)[0]
if entry.get("new_node_ip"):
return str(entry["new_node_ip"])
return f"10.0.0.{index}"
def _render_corosync_conf(config: dict[str, Any], node_names: list[str]) -> str:
clustername = str(config.get("clustername") or "proxmox")
totem = dict(config.get("totem") or {})
added_raw = config.get("added_nodes")
added: dict[str, Any] = dict(added_raw) if isinstance(added_raw, dict) else {}
links = dict(config.get("links") or {})
lines = [
"totem {",
" version: 2",
f" cluster_name: {totem.get('cluster_name', clustername)}",
f" secauth: {totem.get('secauth', 'on')}",
"}",
"nodelist {",
]
for index, name in enumerate(node_names, start=1):
entry_raw = added.get(name)
entry: dict[str, Any] = dict(entry_raw) if isinstance(entry_raw, dict) else {}
nodeid = int(entry.get("nodeid") or index)
votes = int(entry.get("quorum_votes") or entry.get("votes") or config.get("votes") or 1)
ring0 = _ring0_addr(name, index, entry, links)
lines.extend(
[
" node {",
f" name: {name}",
f" nodeid: {nodeid}",
f" quorum_votes: {votes}",
f" ring0_addr: {ring0}",
" }",
]
)
lines.append("}")
lines.extend(["quorum {", " provider: corosync_votequorum", "}"])
return "\n".join(lines) + "\n"
def _ensure_corosync_materials(
config: dict[str, Any], node_names: list[str]
) -> tuple[str, str, str]:
authkey = config.get("corosync_authkey")
if not isinstance(authkey, str) or not authkey:
authkey = secrets.token_hex(32)
config["corosync_authkey"] = authkey
conf = _render_corosync_conf(config, node_names)
config["corosync_conf"] = conf
digest = _stable_digest(conf)
config["config_digest"] = digest
return authkey, conf, digest
async def _cluster_task(
request: Request, *, task_type: str, worker: str, task_id: str = "0"
) -> 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, task_id, _principal(request)))
try:
task = await TaskRepository(pool).create(
upid=upid,
task_type=task_type,
payload={"cluster": True, "worker": worker},
resource_key=f"cluster:{task_type}:{secrets.token_hex(4)}",
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
def register_cluster_config_handlers(registry: HandlerRegistry) -> None: def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]: async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("apiversion", "join", "nodes", "qdevice", "totem") # PVE uses {name}; contract child link is href="{name}".
return [{"name": name} for name in ("nodes", "totem", "join", "qdevice", "apiversion")]
async def create(request: Request, inputs: dict[str, Any]) -> None: async def create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs) payload = values(inputs)
clustername = str(require_value(payload, "clustername"))
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
config = dict(_config(metadata)) config = dict(_config(metadata))
if payload.get("clustername"): # Match PVE: refuse when corosync materials already exist (seeded or prior create).
config["clustername"] = str(payload["clustername"]) if config.get("corosync_conf") or config.get("config_digest"):
raise ApiError(400, "cluster config already exists")
config["clustername"] = clustername
totem = dict(config.get("totem") or {}) totem = dict(config.get("totem") or {})
totem["cluster_name"] = str(payload["clustername"]) totem["version"] = int(totem.get("version") or 2)
totem["secauth"] = totem.get("secauth") or "on"
totem["cluster_name"] = clustername
# PVE defaults token-coefficient to 125 when omitted (majors that expose it).
totem["token_coefficient"] = payload.get("token-coefficient", 125)
config["totem"] = totem config["totem"] = totem
if "votes" in payload: if "votes" in payload:
config["votes"] = payload["votes"] config["votes"] = payload["votes"]
@@ -44,13 +160,43 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
if links: if links:
config["links"] = links config["links"] = links
config["token"] = secrets.token_hex(16) config["token"] = secrets.token_hex(16)
rows = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
node_names = [str(row["name"]) for row in rows]
if not node_names:
node_names = ["localhost"]
creator = node_names[0]
added = dict(config.get("added_nodes") or {})
creator_entry = dict(added.get(creator) or {})
creator_entry.update(
{
"node": creator,
"nodeid": payload.get("nodeid") or creator_entry.get("nodeid") or 1,
"quorum_votes": payload.get("votes") or creator_entry.get("quorum_votes") or 1,
"ring0_addr": _ring0_addr(
creator, 1, creator_entry, dict(config.get("links") or {})
),
"pve_addr": creator_entry.get("pve_addr") or _node_addr(creator, 1, creator_entry),
"pve_fp": creator_entry.get("pve_fp") or _fingerprint_sha256(),
}
)
added[creator] = creator_entry
config["added_nodes"] = added
_ensure_corosync_materials(config, node_names)
metadata["cluster_config"] = config metadata["cluster_config"] = config
await save_cluster_metadata(request, metadata) await save_cluster_metadata(request, metadata)
await database(request).pool.execute( await database(request).pool.execute(
"""UPDATE clusters """UPDATE clusters
SET name=$1, updated_at=now() SET name=$1, updated_at=now()
WHERE id=(SELECT id FROM clusters LIMIT 1)""", WHERE id=(SELECT id FROM clusters LIMIT 1)""",
str(config["clustername"]), clustername,
)
return await _cluster_task(
request,
task_type="cluster-create",
worker="clustercreate",
task_id=clustername,
) )
async def apiversion(_request: Request, _inputs: dict[str, Any]) -> int: async def apiversion(_request: Request, _inputs: dict[str, Any]) -> int:
@@ -60,28 +206,69 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
async def join_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def join_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
config = _config(metadata) config = _config(metadata)
node = values(inputs).get("node") preferred_raw = values(inputs).get("node")
# Contract documents the omitted-node default as this phrase; PVE uses local nodename.
if preferred_raw in {None, "", "current connected node"}:
preferred = None
else:
preferred = str(preferred_raw)
rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name") rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name")
nodelist = [ added_raw = config.get("added_nodes")
{"name": str(row["name"]), "online": 1 if row["status"] == "online" else 0} added: dict[str, Any] = dict(added_raw) if isinstance(added_raw, dict) else {}
for row in rows links = dict(config.get("links") or {})
] nodelist: list[dict[str, Any]] = []
for index, row in enumerate(rows, start=1):
name = str(row["name"])
entry_raw = added.get(name)
entry: dict[str, Any] = dict(entry_raw) if isinstance(entry_raw, dict) else {}
if "pve_fp" not in entry:
entry["pve_fp"] = _fingerprint_sha256()
added[name] = entry
if "pve_addr" not in entry:
entry["pve_addr"] = _node_addr(name, index, entry)
added[name] = entry
nodelist.append(
{
"name": name,
"nodeid": int(entry.get("nodeid") or index),
"quorum_votes": int(
entry.get("quorum_votes") or entry.get("votes") or config.get("votes") or 1
),
"ring0_addr": _ring0_addr(name, index, entry, links),
"pve_addr": str(entry["pve_addr"]),
"pve_fp": str(entry["pve_fp"]),
}
)
if added != dict(added_raw or {}):
updated = dict(config)
updated["added_nodes"] = added
if not updated.get("config_digest"):
_ensure_corosync_materials(updated, [item["name"] for item in nodelist])
metadata["cluster_config"] = updated
await save_cluster_metadata(request, metadata)
config = updated
preferred_node = preferred or (nodelist[0]["name"] if nodelist else None)
digest = config.get("config_digest")
if not isinstance(digest, str) or not digest:
updated = dict(config)
_, _, digest = _ensure_corosync_materials(updated, [item["name"] for item in nodelist])
metadata["cluster_config"] = updated
await save_cluster_metadata(request, metadata)
config = updated
return { return {
"clustername": config.get("clustername"),
"config_digest": secrets.token_hex(8),
"nodelist": nodelist, "nodelist": nodelist,
"preferred_node": node or (nodelist[0]["name"] if nodelist else None), "preferred_node": preferred_node,
"totem": config.get("totem", {}), "totem": dict(config.get("totem") or {}),
"links": config.get("links", {}), "config_digest": digest,
} }
async def join_post(request: Request, inputs: dict[str, Any]) -> None: async def join_post(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs) payload = values(inputs)
hostname = str(require_value(payload, "hostname"))
require_value(payload, "fingerprint")
require_value(payload, "password")
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
config = dict(_config(metadata)) config = dict(_config(metadata))
hostname = str(payload.get("hostname") or payload.get("node") or "")
if not hostname:
raise ApiError(400, "parameter verification failed - 'hostname' missing")
joins = dict(config.get("join_info") or {}) joins = dict(config.get("join_info") or {})
joins[hostname] = { joins[hostname] = {
"hostname": hostname, "hostname": hostname,
@@ -89,23 +276,22 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
"nodeid": payload.get("nodeid"), "nodeid": payload.get("nodeid"),
"votes": payload.get("votes", 1), "votes": payload.get("votes", 1),
"force": payload.get("force"), "force": payload.get("force"),
"password_set": True,
} }
# password accepted but not stored in clear form links = {key: value for key, value in payload.items() if key.startswith("link")}
if payload.get("password"): if links:
joins[hostname]["password_set"] = True joins[hostname]["links"] = links
config["join_info"] = joins config["join_info"] = joins
rows = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
node_names = [str(row["name"]) for row in rows]
_ensure_corosync_materials(config, node_names or ["localhost"])
metadata["cluster_config"] = config metadata["cluster_config"] = config
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) await save_cluster_metadata(request, metadata)
return await _cluster_task(
request,
task_type="cluster-join",
worker="clusterjoin",
)
async def nodes_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]: 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") rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name")
@@ -113,6 +299,7 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
config = _config(metadata) config = _config(metadata)
added_raw = config.get("added_nodes") added_raw = config.get("added_nodes")
added: dict[str, Any] = dict(added_raw) if isinstance(added_raw, dict) else {} added: dict[str, Any] = dict(added_raw) if isinstance(added_raw, dict) else {}
links = dict(config.get("links") or {})
result = [] result = []
for index, row in enumerate(rows, start=1): for index, row in enumerate(rows, start=1):
name = str(row["name"]) name = str(row["name"])
@@ -122,33 +309,40 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
{ {
"node": name, "node": name,
"nodeid": entry.get("nodeid", index), "nodeid": entry.get("nodeid", index),
"ring0_addr": entry.get("ring0_addr", ""), "ring0_addr": _ring0_addr(name, index, entry, links),
"quorum_votes": entry.get("quorum_votes", config.get("votes", 0)), "quorum_votes": entry.get("quorum_votes", config.get("votes", 0)),
} }
) )
return result return result
async def nodes_add(request: Request, inputs: dict[str, Any]) -> None: async def nodes_add(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs) payload = values(inputs)
node = str(payload["node"]) node = str(require_value(payload, "node"))
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
config = dict(_config(metadata)) config = dict(_config(metadata))
added = dict(config.get("added_nodes") or {}) added = dict(config.get("added_nodes") or {})
added[node] = { if node in added and not payload.get("force"):
raise ApiError(400, f"can't add existing node '{node}'")
index = len(added) + 1
links = {key: value for key, value in payload.items() if key.startswith("link")}
entry = {
"node": node, "node": node,
"nodeid": payload.get("nodeid"), "nodeid": payload.get("nodeid") or index,
"new_node_ip": payload.get("new_node_ip"), "new_node_ip": payload.get("new_node_ip"),
"votes": payload.get("votes", 1), "votes": payload.get("votes", 1),
"apiversion": payload.get("apiversion"), "apiversion": payload.get("apiversion"),
"force": payload.get("force"), "force": payload.get("force"),
"ring0_addr": payload.get("ring0_addr") or f"{node}.local", "ring0_addr": payload.get("ring0_addr")
or payload.get("new_node_ip")
or _ring0_addr(node, index, {}, links or dict(config.get("links") or {})),
"quorum_votes": payload.get("quorum_votes", payload.get("votes", 1)), "quorum_votes": payload.get("quorum_votes", payload.get("votes", 1)),
"pve_addr": payload.get("new_node_ip") or _node_addr(node, index, {}),
"pve_fp": _fingerprint_sha256(),
} }
links = {key: value for key, value in payload.items() if key.startswith("link")}
if links: if links:
added[node]["links"] = links entry["links"] = links
added[node] = entry
config["added_nodes"] = added config["added_nodes"] = added
metadata["cluster_config"] = config
exists = await database(request).pool.fetchval( exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", "SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
node, node,
@@ -159,7 +353,18 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
VALUES(gen_random_uuid(), $1, 'online', '{}'::jsonb)""", VALUES(gen_random_uuid(), $1, 'online', '{}'::jsonb)""",
node, node,
) )
rows = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
node_names = [str(row["name"]) for row in rows]
if node not in node_names:
node_names.append(node)
authkey, conf, _digest = _ensure_corosync_materials(config, node_names)
metadata["cluster_config"] = config
await save_cluster_metadata(request, metadata) await save_cluster_metadata(request, metadata)
return {
"corosync_authkey": authkey,
"corosync_conf": conf,
"warnings": [],
}
async def nodes_delete(request: Request, inputs: dict[str, Any]) -> None: async def nodes_delete(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
@@ -171,6 +376,9 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
joins.pop(node, None) joins.pop(node, None)
config["added_nodes"] = added config["added_nodes"] = added
config["join_info"] = joins config["join_info"] = joins
rows = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
node_names = [str(row["name"]) for row in rows if str(row["name"]) != node]
_ensure_corosync_materials(config, node_names or ["localhost"])
metadata["cluster_config"] = config metadata["cluster_config"] = config
await save_cluster_metadata(request, metadata) await save_cluster_metadata(request, metadata)
# Keep node row; mark offline to avoid cascading guest deletes. # Keep node row; mark offline to avoid cascading guest deletes.
+149 -26
View File
@@ -15,9 +15,11 @@ from app.handlers.common import (
database, database,
require_node, require_node,
save_cluster_metadata, save_cluster_metadata,
state,
subdirs, subdirs,
values, values,
) )
from app.simulation.ceph_defaults import CEPH_FLAG_DESCRIPTIONS, default_ceph_flags
from app.tasks.repository import TaskRepository from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid from app.tasks.upid import Upid
@@ -55,10 +57,9 @@ def _save_ha_rules(metadata: dict[str, Any], rules: list[dict[str, Any]]) -> Non
def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]: def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
jobs = metadata.get("replication", []) from app.handlers.cluster import _replication_jobs as normalize
if not isinstance(jobs, list):
return [] return normalize(metadata)
return [dict(item) for item in jobs if isinstance(item, dict)]
def _ceph(metadata: dict[str, Any]) -> dict[str, Any]: def _ceph(metadata: dict[str, Any]) -> dict[str, Any]:
@@ -189,10 +190,42 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
async def metrics_export(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]: async def metrics_export(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
metrics = _metrics(metadata) metrics = _metrics(metadata)
return { now = int(time.time())
"data": str(metrics.get("export_data") or ""), seeded = metrics.get("export_series")
"timestamp": int(time.time()), if isinstance(seeded, list) and seeded:
series = [dict(item) for item in seeded if isinstance(item, dict)]
return {"data": series, "timestamp": now}
# Build a PVE-shaped metric array from live inventory (scales with seed).
rows = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
series: list[dict[str, Any]] = []
for row in rows:
node = str(row["name"])
series.append(
{
"id": f"node/{node}",
"metric": "up",
"timestamp": now,
"type": "gauge",
"value": 1,
} }
)
guests = await database(request).pool.fetch(
"""SELECT r.kind, r.external_id, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind IN ('qemu', 'lxc')
ORDER BY r.external_id::integer"""
)
for guest in guests:
series.append(
{
"id": f"{guest['kind']}/{guest['external_id']}",
"metric": "up",
"timestamp": now,
"type": "gauge",
"value": 1,
}
)
return {"data": series, "timestamp": now}
async def metrics_server_list( async def metrics_server_list(
request: Request, _inputs: dict[str, Any] request: Request, _inputs: dict[str, Any]
@@ -398,43 +431,69 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
return subdirs("flags", "metadata", "status") return subdirs("flags", "metadata", "status")
async def ceph_flags_get(request: Request, _inputs: dict[str, Any]) -> dict[str, int]: async def ceph_flags_get(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
ceph = _ceph(metadata) ceph = _ceph(metadata)
flags = ceph.get("flags") stored = ceph.get("flags")
if not isinstance(flags, dict): flags = default_ceph_flags()
return {} if isinstance(stored, dict):
return {str(key): int(value) for key, value in flags.items()} for name, value in stored.items():
if name in flags:
flags[name] = int(value)
return [
{
"name": name,
"value": bool(flags[name]),
"description": description,
}
for name, description in sorted(CEPH_FLAG_DESCRIPTIONS.items())
]
async def ceph_flags_put(request: Request, inputs: dict[str, Any]) -> dict[str, int]: async def ceph_flags_put(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs) payload = values(inputs)
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
ceph = dict(_ceph(metadata)) ceph = dict(_ceph(metadata))
flags = dict(ceph.get("flags") or {}) flags = default_ceph_flags()
stored = ceph.get("flags")
if isinstance(stored, dict):
for name, value in stored.items():
if name in flags:
flags[name] = int(value)
for key, value in payload.items(): for key, value in payload.items():
if key in {"delete", "digest"}: if key in {"delete", "digest"}:
continue continue
if key not in CEPH_FLAG_DESCRIPTIONS:
raise ApiError(400, f"unknown ceph flag '{key}'")
flags[str(key)] = int(value) flags[str(key)] = int(value)
ceph["flags"] = flags ceph["flags"] = flags
metadata["ceph"] = ceph metadata["ceph"] = ceph
await save_cluster_metadata(request, metadata) await save_cluster_metadata(request, metadata)
return {str(key): int(value) for key, value in flags.items()} return await _cluster_task(request, task_type="ceph-flags", worker="cephflags")
async def ceph_flag_get(request: Request, inputs: dict[str, Any]) -> dict[str, int]: async def ceph_flag_get(request: Request, inputs: dict[str, Any]) -> bool:
flag = str(values(inputs)["flag"]) flag = str(values(inputs)["flag"])
if flag not in CEPH_FLAG_DESCRIPTIONS:
raise ApiError(404, "ceph flag does not exist")
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
ceph = _ceph(metadata) ceph = _ceph(metadata)
flags = ceph.get("flags") flags = ceph.get("flags")
if not isinstance(flags, dict) or flag not in flags: if isinstance(flags, dict) and flag in flags:
raise ApiError(404, "ceph flag does not exist") return bool(int(flags[flag]))
return {flag: int(flags[flag])} return False
async def ceph_flag_put(request: Request, inputs: dict[str, Any]) -> dict[str, int]: async def ceph_flag_put(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs) payload = values(inputs)
flag = str(payload["flag"]) flag = str(payload["flag"])
if flag not in CEPH_FLAG_DESCRIPTIONS:
raise ApiError(404, "ceph flag does not exist")
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
ceph = dict(_ceph(metadata)) ceph = dict(_ceph(metadata))
flags = dict(ceph.get("flags") or {}) flags = default_ceph_flags()
stored = ceph.get("flags")
if isinstance(stored, dict):
for name, value in stored.items():
if name in flags:
flags[name] = int(value)
if "value" in payload: if "value" in payload:
flags[flag] = int(payload["value"]) flags[flag] = int(payload["value"])
elif flag in payload: elif flag in payload:
@@ -444,7 +503,6 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
ceph["flags"] = flags ceph["flags"] = flags
metadata["ceph"] = ceph metadata["ceph"] = ceph
await save_cluster_metadata(request, metadata) await save_cluster_metadata(request, metadata)
return {flag: int(flags[flag])}
async def ceph_metadata(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]: async def ceph_metadata(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
@@ -452,12 +510,77 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
config_raw = ceph.get("config") config_raw = ceph.get("config")
config: dict[str, Any] = dict(config_raw) if isinstance(config_raw, dict) else {} config: dict[str, Any] = dict(config_raw) if isinstance(config_raw, dict) else {}
version = ceph.get("version") version = ceph.get("version")
flags = ceph.get("flags") nodes = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
node_names = [str(row["name"]) for row in nodes]
version_str = (
str(version.get("str"))
if isinstance(version, dict) and version.get("str")
else "18.2.2"
)
version_short = ".".join(version_str.split(".")[:2]) if version_str else "18.2"
osd_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='ceph-osd' ORDER BY r.external_id"""
)
osd_map: dict[str, Any] = {}
for row in osd_rows:
payload = state(row["state"])
external = str(row["external_id"])
osd_id = str(payload.get("osd_id", external.removeprefix("osd.")))
hostname = str(row["node"])
osd_map[osd_id] = {
"id": int(osd_id) if osd_id.isdigit() else osd_id,
"hostname": hostname,
"name": f"osd.{osd_id}",
"ceph_version": version_str,
"ceph_version_short": version_short,
"ceph_release": "reef",
"device_class": payload.get("device_class", "hdd"),
"dev": payload.get("dev", ""),
}
if not osd_map:
fallback_host = node_names[0] if node_names else "pve01"
osd_map["0"] = {
"id": 0,
"hostname": fallback_host,
"name": "osd.0",
"ceph_version": version_str,
"ceph_version_short": version_short,
"ceph_release": "reef",
}
def _service_entry(name: str, index: int) -> dict[str, Any]:
return { return {
"version": dict(version) if isinstance(version, dict) else {}, "name": name,
"hostname": name,
"addr": f"10.0.0.{index}",
"ceph_version": version_str,
"ceph_version_short": version_short,
"ceph_release": "reef",
}
return {
"mds": {},
"mgr": {
f"mgr@{node}": _service_entry(node, index)
for index, node in enumerate(node_names[:1] or ["pve01"], start=1)
},
"mon": {
f"mon@{node}": _service_entry(node, index)
for index, node in enumerate(node_names[:3] or ["pve01"], start=1)
},
"node": {
node: {
"name": node,
"hostname": node,
"ceph_version": version_str,
"ceph_version_short": version_short,
}
for node in (node_names or ["pve01"])
},
"osd": list(osd_map.values()),
"fsid": str(config.get("fsid") or ""), "fsid": str(config.get("fsid") or ""),
"initialized": int(bool(ceph.get("initialized"))),
"flags": dict(flags) if isinstance(flags, dict) else {},
} }
async def ha_rule_create(request: Request, inputs: dict[str, Any]) -> None: async def ha_rule_create(request: Request, inputs: dict[str, Any]) -> None:
+14 -3
View File
@@ -129,13 +129,24 @@ def storage_payload(row: Any) -> dict[str, Any]:
total = int(row["capacity_bytes"] or 0) total = int(row["capacity_bytes"] or 0)
used = int(row["used_bytes"] or 0) used = int(row["used_bytes"] or 0)
avail = max(total - used, 0) avail = max(total - used, 0)
storage_type = str(row["storage_type"])
formats = config.get("formats")
if not isinstance(formats, str) or not formats:
formats = {
"dir": "raw,qcow2,vmdk",
"lvmthin": "raw",
"rbd": "raw",
"zfspool": "raw,subvol",
}.get(storage_type, "raw")
payload: dict[str, Any] = { payload: dict[str, Any] = {
"storage": str(row["storage_id"]), "storage": str(row["storage_id"]),
"type": str(row["storage_type"]), "type": storage_type,
"shared": int(bool(row["shared"])), "shared": int(bool(row["shared"])),
"content": content_str, "content": content_str,
"active": 1, "active": int(bool(config.get("active", True))),
"enabled": 1, "enabled": int(bool(config.get("enabled", True))),
"formats": formats,
"select_existing": int(bool(config.get("select_existing", False))),
"total": total, "total": total,
"used": used, "used": used,
"avail": avail, "avail": avail,
+146 -15
View File
@@ -19,7 +19,7 @@ from app.handlers.ceph import register_ceph_handlers
from app.handlers.cluster import register_cluster_handlers from app.handlers.cluster import register_cluster_handlers
from app.handlers.cluster_config import register_cluster_config_handlers from app.handlers.cluster_config import register_cluster_config_handlers
from app.handlers.cluster_extra import register_cluster_extra_handlers from app.handlers.cluster_extra import register_cluster_extra_handlers
from app.handlers.common import require_node, subdirs from app.handlers.common import cluster_metadata, require_node, subdirs
from app.handlers.firewall import register_firewall_handlers from app.handlers.firewall import register_firewall_handlers
from app.handlers.ha import register_ha_handlers from app.handlers.ha import register_ha_handlers
from app.handlers.legacy_aliases import register_legacy_aliases from app.handlers.legacy_aliases import register_legacy_aliases
@@ -60,10 +60,14 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
raise ApiError(401, "authentication failure") raise ApiError(401, "authentication failure")
key = settings.ticket_signing_key.get_secret_value().encode() key = settings.ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(username, key) ticket = issue_ticket(username, key)
cluster_name = await _database(request).pool.fetchval(
"SELECT name FROM clusters ORDER BY created_at LIMIT 1"
)
return { return {
"username": username, "username": username,
"ticket": ticket, "ticket": ticket,
"CSRFPreventionToken": csrf_token(ticket, key), "CSRFPreventionToken": csrf_token(ticket, key),
"clustername": str(cluster_name or "pve-simulator"),
"cap": {"vms": {"VM.Audit": 1, "VM.PowerMgmt": 1}}, "cap": {"vms": {"VM.Audit": 1, "VM.PowerMgmt": 1}},
} }
@@ -81,7 +85,9 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
status_dict = dict(status_payload) if isinstance(status_payload, dict) else {} status_dict = dict(status_payload) if isinstance(status_payload, dict) else {}
fingerprint = status_dict.get("ssl_fingerprint") or status_dict.get("fingerprint") fingerprint = status_dict.get("ssl_fingerprint") or status_dict.get("fingerprint")
if fingerprint in (None, "", 0) or isinstance(fingerprint, dict | list): if fingerprint in (None, "", 0) or isinstance(fingerprint, dict | list):
fingerprint = ":".join(["00"] * 32) from app.simulation.seed import _seed_fingerprint
fingerprint = _seed_fingerprint(name)
def _as_float(value: object, default: float) -> float: def _as_float(value: object, default: float) -> float:
if isinstance(value, bool) or value is None or isinstance(value, dict | list): if isinstance(value, bool) or value is None or isinstance(value, dict | list):
@@ -126,10 +132,62 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
ops = await load_node_ops(request, node) ops = await load_node_ops(request, node)
status = ops.get("status") status = ops.get("status")
payload = dict(status) if isinstance(status, dict) else {} payload = dict(status) if isinstance(status, dict) else {}
mem_used = int(payload.get("mem") or 0)
mem_total = int(payload.get("maxmem") or 8 * 1024**3)
memory = payload.get("memory")
if isinstance(memory, dict):
mem_used = int(memory.get("used") or mem_used)
mem_total = int(memory.get("total") or mem_total)
rootfs = payload.get("rootfs")
if not isinstance(rootfs, dict):
rootfs = {
"used": int(payload.get("rootfs_used") or mem_used // 8),
"total": int(payload.get("rootfs_total") or 100 * 1024**3),
"avail": int(payload.get("rootfs_avail") or 80 * 1024**3),
"free": int(payload.get("rootfs_free") or 80 * 1024**3),
}
cpuinfo = payload.get("cpuinfo")
if not isinstance(cpuinfo, dict):
cpuinfo = {
"cpus": int(payload.get("maxcpu") or 4),
"cores": int(payload.get("maxcpu") or 4),
"sockets": 1,
"model": str(payload.get("cpu_model") or "QEMU Virtual CPU"),
"flags": "",
}
boot_info = payload.get("boot-info")
if not isinstance(boot_info, dict):
boot_info = {"mode": "efi", "secureboot": 0}
current_kernel = payload.get("current-kernel")
if not isinstance(current_kernel, dict):
kversion = str(payload.get("kversion") or "6.8.12-1-pve")
current_kernel = {
"sysname": "Linux",
"release": kversion,
"version": f"#1 SMP PREEMPT_DYNAMIC {kversion}",
"machine": "x86_64",
}
return { return {
"status": str(row["status"]), "uptime": int(payload.get("uptime") or 0),
"node": str(row["name"]), "wait": float(payload.get("wait") or 0.0),
**payload, "idle": float(payload.get("idle") or 0.95),
"cpu": float(payload.get("cpu") or 0.0),
"loadavg": list(payload.get("loadavg") or ["0.00", "0.00", "0.00"]),
"memory": {
"used": mem_used,
"total": mem_total,
"free": max(mem_total - mem_used, 0),
"available": max(mem_total - mem_used, 0),
},
"rootfs": rootfs,
"swap": payload.get("swap")
if isinstance(payload.get("swap"), dict)
else {"used": 0, "total": 0, "free": 0},
"cpuinfo": cpuinfo,
"kversion": str(payload.get("kversion") or current_kernel.get("release") or ""),
"pveversion": str(payload.get("pveversion") or "pve-manager/9.2.3"),
"current-kernel": current_kernel,
"boot-info": boot_info,
} }
async def resources(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def resources(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
@@ -197,6 +255,15 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
ops = await load_node_ops(request, name) ops = await load_node_ops(request, name)
status_payload = ops.get("status") status_payload = ops.get("status")
status_dict = dict(status_payload) if isinstance(status_payload, dict) else {} status_dict = dict(status_payload) if isinstance(status_payload, dict) else {}
mem_raw = status_dict.get("mem", status_dict.get("memory"))
if isinstance(mem_raw, dict):
mem_val = int(_num(mem_raw.get("used"), 0))
maxmem_val = int(
_num(status_dict.get("maxmem", mem_raw.get("total")), 8 * 1024**3)
)
else:
mem_val = int(_num(mem_raw, 0))
maxmem_val = int(_num(status_dict.get("maxmem"), 8 * 1024**3))
result.append( result.append(
{ {
"type": "node", "type": "node",
@@ -205,10 +272,8 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"status": str(row["status"]), "status": str(row["status"]),
"cpu": float(_num(status_dict.get("cpu"), 0.0)), "cpu": float(_num(status_dict.get("cpu"), 0.0)),
"maxcpu": int(_num(status_dict.get("maxcpu"), 4)), "maxcpu": int(_num(status_dict.get("maxcpu"), 4)),
"mem": int( "mem": mem_val,
_num(status_dict.get("mem"), _num(status_dict.get("memory"), 0)) "maxmem": maxmem_val,
),
"maxmem": int(_num(status_dict.get("maxmem"), 8 * 1024**3)),
"uptime": int(_num(status_dict.get("uptime"), 0)), "uptime": int(_num(status_dict.get("uptime"), 0)),
"level": str(status_dict.get("level") or ""), "level": str(status_dict.get("level") or ""),
} }
@@ -220,12 +285,40 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
elif type_filter == "storage": elif type_filter == "storage":
kind_filter = ("storage",) kind_filter = ("storage",)
elif type_filter in (None,): elif type_filter in (None,):
kind_filter = ("qemu", "lxc", "storage") kind_filter = ("qemu", "lxc", "storage", "pool")
elif type_filter in {"qemu", "lxc", "storage", "pool", "sdn"}: elif type_filter in {"qemu", "lxc", "storage", "pool", "sdn"}:
kind_filter = (str(type_filter),) kind_filter = (str(type_filter),)
else: else:
kind_filter = () kind_filter = ()
ha_by_guest: dict[str, str] = {}
if kind_filter and ("qemu" in kind_filter or "lxc" in kind_filter):
ha_rows = await _database(request).pool.fetch(
"SELECT external_id, state FROM resources WHERE kind='ha'"
)
for ha_row in ha_rows:
sid = str(ha_row["external_id"])
ha_state = _as_dict(ha_row["state"])
ha_by_guest[sid] = str(ha_state.get("state") or "started")
pool_by_vmid: dict[str, str] = {}
if kind_filter and ("qemu" in kind_filter or "lxc" in kind_filter or "pool" in kind_filter):
membership = await _database(request).pool.fetch(
"""SELECT p.pool_id AS pool, r.external_id AS vmid
FROM pool_members pm
JOIN pools p ON p.id=pm.pool_id
JOIN resources r ON r.id=pm.resource_id"""
)
for row in membership:
pool_by_vmid[str(row["vmid"])] = str(row["pool"])
node_names = [
str(item["node"])
for item in await _database(request).pool.fetch(
"SELECT name AS node FROM nodes ORDER BY name"
)
]
if kind_filter: if kind_filter:
rows = await _database(request).pool.fetch( rows = await _database(request).pool.fetch(
"""SELECT r.kind AS type, r.external_id, r.state, n.name AS node """SELECT r.kind AS type, r.external_id, r.state, n.name AS node
@@ -243,6 +336,7 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
status = str(state.get("status") or "stopped") status = str(state.get("status") or "stopped")
running = status in {"running", "paused"} running = status in {"running", "paused"}
vmid = int(external_id) vmid = int(external_id)
sid = f"{'vm' if kind == 'qemu' else 'ct'}:{external_id}"
item: dict[str, Any] = { item: dict[str, Any] = {
"type": kind, "type": kind,
"id": f"{kind}/{external_id}", "id": f"{kind}/{external_id}",
@@ -250,7 +344,7 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"vmid": vmid, "vmid": vmid,
"name": str(state.get("name") or f"{kind}-{external_id}"), "name": str(state.get("name") or f"{kind}-{external_id}"),
"status": status, "status": status,
"template": 1 if state.get("template") in {True, "1"} else 0, "template": bool(state.get("template") in {True, "1", 1}),
"cpu": _cpu_util(state, running=running), "cpu": _cpu_util(state, running=running),
"maxcpu": _maxcpu(state), "maxcpu": _maxcpu(state),
"mem": int(_num(state.get("mem"), 0)) if running else 0, "mem": int(_num(state.get("mem"), 0)) if running else 0,
@@ -259,29 +353,66 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"maxdisk": int(_num(state.get("maxdisk"), 0)), "maxdisk": int(_num(state.get("maxdisk"), 0)),
"uptime": int(_num(state.get("uptime"), 0)) if running else 0, "uptime": int(_num(state.get("uptime"), 0)) if running else 0,
} }
if sid in ha_by_guest:
item["hastate"] = ha_by_guest[sid]
if external_id in pool_by_vmid:
item["pool"] = pool_by_vmid[external_id]
result.append(item) result.append(item)
elif kind == "storage": elif kind == "storage":
content = state.get("content") content = state.get("content")
if isinstance(content, list): if isinstance(content, list):
content_text = ",".join(str(item) for item in content) content_text = ",".join(str(part) for part in content)
else: else:
content_text = str(content or "") content_text = str(content or "")
shared = bool(_num(state.get("shared"), 0))
# Schema allows one storages row per storage_id; expand to every
# node like a real PVE dump (local + shared storages).
targets = node_names if (shared or len(node_names) > 1) else [node]
if not shared and external_id not in {"local", "local-lvm", "ceph"}:
targets = [node]
for target in targets:
result.append( result.append(
{ {
"type": "storage", "type": "storage",
"id": f"storage/{node}/{external_id}", "id": f"storage/{target}/{external_id}",
"node": node, "node": target,
"storage": external_id, "storage": external_id,
"status": str(state.get("status") or "available"), "status": str(state.get("status") or "available"),
"content": content_text, "content": content_text,
"disk": int(_num(state.get("disk"), 0)), "disk": int(_num(state.get("disk"), 0)),
"maxdisk": int(_num(state.get("maxdisk"), 1 * 1024**3)), "maxdisk": int(_num(state.get("maxdisk"), 1 * 1024**3)),
"shared": int(_num(state.get("shared"), 0)), "shared": shared,
"plugintype": str( "plugintype": str(
state.get("plugintype") or state.get("type") or "dir" state.get("plugintype") or state.get("type") or "dir"
), ),
} }
) )
elif kind == "pool":
result.append(
{
"type": "pool",
"id": f"pool/{external_id}",
"pool": external_id,
"comment": str(state.get("comment") or ""),
}
)
if type_filter in (None, "sdn"):
metadata = await cluster_metadata(request)
sdn = metadata.get("sdn")
zones = sdn.get("zones") if isinstance(sdn, dict) else None
if isinstance(zones, dict):
for zone_name, zone in sorted(zones.items()):
if not isinstance(zone, dict):
continue
result.append(
{
"type": "sdn",
"id": f"sdn/{zone_name}",
"sdn": zone_name,
"status": str(zone.get("status") or "ok"),
}
)
return result return result
async def node_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]: async def node_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
+1 -2
View File
@@ -112,7 +112,7 @@ def register_firewall_handlers(registry: HandlerRegistry) -> None:
options = section.get("options", {}) options = section.get("options", {})
return dict(options) if isinstance(options, dict) else {} return dict(options) if isinstance(options, dict) else {}
async def options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def options_put(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs) payload = await _ready(request, inputs)
firewall = await _load_firewall(request) firewall = await _load_firewall(request)
section = _ensure_scope(firewall, scope_fn(payload)) section = _ensure_scope(firewall, scope_fn(payload))
@@ -123,7 +123,6 @@ def register_firewall_handlers(registry: HandlerRegistry) -> None:
current[key] = value current[key] = value
section["options"] = current section["options"] = current
await _save_firewall(request, firewall) await _save_firewall(request, firewall)
return current
async def rules_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def rules_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs) payload = await _ready(request, inputs)
+79 -20
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
from typing import Any from typing import Any
@@ -27,6 +28,13 @@ def _ha_groups(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {str(key): dict(value) for key, value in groups.items() if isinstance(value, dict)} return {str(key): dict(value) for key, value in groups.items() if isinstance(value, dict)}
def _ha_digest(payload: dict[str, Any]) -> str:
material = {key: value for key, value in payload.items() if key != "digest"}
return hashlib.sha1(
json.dumps(material, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
def register_ha_handlers(registry: HandlerRegistry) -> None: def register_ha_handlers(registry: HandlerRegistry) -> None:
async def ha_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]: async def ha_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("groups", "resources", "rules", "status") return subdirs("groups", "resources", "rules", "status")
@@ -41,8 +49,7 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
for row in rows: for row in rows:
payload = state(row["state"]) payload = state(row["state"])
sid = str(row["external_id"]) sid = str(row["external_id"])
result.append( entry = {
{
"sid": sid, "sid": sid,
"type": "vm" if sid.startswith("vm:") else "ct", "type": "vm" if sid.startswith("vm:") else "ct",
"state": payload.get("state", "started"), "state": payload.get("state", "started"),
@@ -51,7 +58,8 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
"max_relocate": payload.get("max_relocate", 1), "max_relocate": payload.get("max_relocate", 1),
"max_restart": payload.get("max_restart", 1), "max_restart": payload.get("max_restart", 1),
} }
) entry["digest"] = _ha_digest(entry)
result.append(entry)
return result return result
async def ha_resource_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def ha_resource_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
@@ -208,29 +216,75 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
async def ha_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]: async def ha_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("current", "manager_status") return subdirs("current", "manager_status")
async def ha_status_current(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]: async def ha_status_current(request: Request, _inputs: dict[str, Any]) -> list[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( master = await database(request).pool.fetchval(
"SELECT name FROM nodes WHERE status='online' ORDER BY name LIMIT 1" "SELECT name FROM nodes WHERE status='online' ORDER BY name LIMIT 1"
) )
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
ha_raw = metadata.get("ha") ha_raw = metadata.get("ha")
ha: dict[str, Any] = dict(ha_raw) if isinstance(ha_raw, dict) else {} ha: dict[str, Any] = dict(ha_raw) if isinstance(ha_raw, dict) else {}
armed = bool(ha.get("armed")) if "armed" in ha else False
status_raw = ha.get("status_current") status_raw = ha.get("status_current")
status: dict[str, Any] = dict(status_raw) if isinstance(status_raw, dict) else {} status: dict[str, Any] = dict(status_raw) if isinstance(status_raw, dict) else {}
armed = bool(ha.get("armed")) if "armed" in ha else False quorate = bool(int(status.get("quorate", metadata.get("quorate", 1)) or 1))
return { master_node = str(status.get("master_node") or master or "")
"quorate": status.get("quorate", metadata.get("quorate", 0)), rows = await database(request).pool.fetch(
"mode": status.get("mode", "active" if armed else "disabled"), """SELECT r.external_id, r.state, n.name AS node
"master_node": status.get("master_node", str(master or "")), FROM resources r JOIN nodes n ON n.id=r.node_id
"ha_started": int(row["started"] or 0), WHERE r.kind='ha' ORDER BY r.external_id"""
"ha_total": int(row["total"] or 0), )
"armed": 1 if armed else 0, nodes = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
result: list[dict[str, Any]] = [
{
"id": "quorum",
"type": "quorum",
"node": master_node,
"status": "OK" if quorate else "offline",
"quorate": quorate,
},
{
"id": "master",
"type": "master",
"node": master_node,
"status": "active" if armed else "disabled",
},
{
"id": "fencing",
"type": "fencing",
"node": master_node,
"status": "armed" if armed else "disarmed",
"armed-state": "armed" if armed else "disarmed",
},
]
for node_row in nodes:
node_name = str(node_row["name"])
result.append(
{
"id": f"lrm:{node_name}",
"type": "lrm",
"node": node_name,
"status": "active" if quorate else "offline",
} }
)
for row in rows:
payload = state(row["state"])
sid = str(row["external_id"])
crm = str(payload.get("state") or "started")
result.append(
{
"id": f"service:{sid}",
"type": "service",
"sid": sid,
"node": str(row["node"]),
"crm_state": crm,
"state": crm,
"status": crm,
"request_state": str(payload.get("request_state") or crm),
"max_relocate": int(payload.get("max_relocate") or 1),
"max_restart": int(payload.get("max_restart") or 1),
}
)
return result
async def ha_manager_status(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]: async def ha_manager_status(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
@@ -252,7 +306,7 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
return [dict(item) for item in rules if isinstance(item, dict)] return [dict(item) for item in rules if isinstance(item, dict)]
return [] return []
async def ha_relocate(request: Request, inputs: dict[str, Any]) -> None: async def ha_relocate(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs) payload = values(inputs)
sid = str(payload["sid"]) sid = str(payload["sid"])
target = str(payload.get("node") or payload.get("target") or "") target = str(payload.get("node") or payload.get("target") or "")
@@ -292,9 +346,14 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
ha_row["id"], ha_row["id"],
json.dumps(current, sort_keys=True), json.dumps(current, sort_keys=True),
) )
return {
"sid": sid,
"requested-node": target,
"comigrated-resources": [],
}
async def ha_migrate(request: Request, inputs: dict[str, Any]) -> None: async def ha_migrate(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await ha_relocate(request, inputs) return await ha_relocate(request, inputs)
async def ha_arm(request: Request, _inputs: dict[str, Any]) -> None: async def ha_arm(request: Request, _inputs: dict[str, Any]) -> None:
metadata = await cluster_metadata(request) metadata = await cluster_metadata(request)
+1 -1
View File
@@ -19,7 +19,7 @@ def register_legacy_aliases(registry: HandlerRegistry) -> None:
alias("/access/tfa", "POST", "/access/tfa/{userid}", "POST") alias("/access/tfa", "POST", "/access/tfa/{userid}", "POST")
alias("/access/tfa", "PUT", "/access/tfa/{userid}/{id}", "PUT") alias("/access/tfa", "PUT", "/access/tfa/{userid}/{id}", "PUT")
alias("/cluster/backupinfo", "GET", "/cluster/backup-info", "GET") # /cluster/backupinfo is registered as a PVE 6 stub string in backup.py
alias( alias(
"/cluster/backupinfo/not_backed_up", "/cluster/backupinfo/not_backed_up",
"GET", "GET",
+110 -8
View File
@@ -38,27 +38,120 @@ def _state(value: object) -> dict[str, Any]:
return dict(cast(Mapping[str, Any], value)) return dict(cast(Mapping[str, Any], value))
# Runtime-only keys stored in ``resources.state``; never echo on GET /config.
_LXC_CONFIG_INTERNAL = frozenset(
{
"agent",
"cloudinit_dump",
"config",
"consoles",
"interfaces",
"migrate_preconditions",
"name",
"rrd",
"rrddata",
"status",
"volume_moves",
}
)
def _public_lxc_config(
config: dict[str, Any], state: dict[str, Any], *, vmid: int
) -> dict[str, Any]:
import hashlib
merged = {**state, **config}
payload: dict[str, Any] = {"vmid": vmid}
for key, value in merged.items():
if key in _LXC_CONFIG_INTERNAL:
continue
if isinstance(value, dict | list):
continue
payload[key] = value
digest_src = {key: value for key, value in payload.items() if key != "digest"}
payload["digest"] = hashlib.sha1(
json.dumps(digest_src, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
return payload
def _lxc_index_item(vmid: int, vm_state: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
status = str(vm_state.get("status", "stopped"))
running = status in {"running", "paused"}
memory_mb = int(config.get("memory", config.get("mem", 512)))
maxmem = memory_mb * 2**20
return {
"vmid": vmid,
"name": str(config.get("hostname", config.get("name", f"ct-{vmid}"))),
"status": status,
"cpus": int(config.get("cores", config.get("cpus", 1))),
"cpu": float(vm_state.get("cpu", 0.05 if running else 0.0)),
"maxmem": maxmem,
"mem": int(vm_state.get("mem", maxmem // 2 if running else 0)),
"maxdisk": int(vm_state.get("maxdisk", config.get("maxdisk", 8 * 1024**3))),
"disk": int(vm_state.get("disk", 0)),
"diskread": int(vm_state.get("diskread", 0)),
"diskwrite": int(vm_state.get("diskwrite", 0)),
"netin": int(vm_state.get("netin", 0)),
"netout": int(vm_state.get("netout", 0)),
"maxswap": int(config.get("swap", 512)) * 2**20,
"uptime": int(vm_state.get("uptime", 0)) if running else 0,
"lock": str(vm_state.get("lock", "")),
"tags": str(config.get("tags", "")),
"template": int(bool(vm_state.get("template", False))),
"pressurecpusome": float(vm_state.get("pressurecpusome", 0.0)),
"pressurecpufull": float(vm_state.get("pressurecpufull", 0.0)),
"pressureiosome": float(vm_state.get("pressureiosome", 0.0)),
"pressureiofull": float(vm_state.get("pressureiofull", 0.0)),
"pressurememorysome": float(vm_state.get("pressurememorysome", 0.0)),
"pressurememoryfull": float(vm_state.get("pressurememoryfull", 0.0)),
}
def register_lxc_handlers(registry: HandlerRegistry) -> None: def register_lxc_handlers(registry: HandlerRegistry) -> None:
async def lxc_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def lxc_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(_values(inputs)["node"]) node = str(_values(inputs)["node"])
rows = await _database(request).pool.fetch( rows = await _database(request).pool.fetch(
"""SELECT r.external_id::integer AS vmid, r.state """SELECT r.external_id::integer AS vmid, r.state, c.config
FROM resources r JOIN nodes n ON n.id=r.node_id 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' ORDER BY r.external_id::integer""", WHERE n.name=$1 AND r.kind='lxc' ORDER BY r.external_id::integer""",
node, node,
) )
return [{"vmid": int(row["vmid"]), **_state(row["state"])} for row in rows] return [
_lxc_index_item(int(row["vmid"]), _state(row["state"]), _state(row["config"]))
for row in rows
]
async def lxc_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def lxc_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"]) node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
row = await _lxc_resource(request, node, vmid) row = await _lxc_resource(request, node, vmid)
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])} return _public_lxc_config(_state(row["config"]), _state(row["state"]), vmid=int(vmid))
async def lxc_current(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def lxc_current(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await lxc_config(request, inputs) payload = _values(inputs)
node, vmid = str(payload["node"]), int(payload["vmid"])
row = await _lxc_resource(request, node, str(vmid))
vm_state = _state(row["state"])
config = _state(row["config"])
item = _lxc_index_item(vmid, vm_state, config)
item["ha"] = {"managed": int(vm_state.get("ha_managed", 0))}
return item
async def lxc_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def lxc_status(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
return await lxc_config(request, inputs) payload = _values(inputs)
await _lxc_resource(request, str(payload["node"]), str(payload["vmid"]))
return subdirs(
"current",
"reboot",
"resume",
"shutdown",
"start",
"stop",
"suspend",
)
async def mutate(operation: str, request: Request, inputs: dict[str, Any]) -> str: async def mutate(operation: str, request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs) values = _values(inputs)
@@ -421,7 +514,7 @@ def register_lxc_handlers(registry: HandlerRegistry) -> None:
} }
} }
async def lxc_resize(request: Request, inputs: dict[str, Any]) -> None: async def lxc_resize(request: Request, inputs: dict[str, Any]) -> str:
payload = _values(inputs) payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"]) node, vmid = str(payload["node"]), str(payload["vmid"])
disk = str(payload.get("disk") or "rootfs") disk = str(payload.get("disk") or "rootfs")
@@ -446,6 +539,13 @@ def register_lxc_handlers(registry: HandlerRegistry) -> None:
resource["id"], resource["id"],
json.dumps({disk: config[disk]}, sort_keys=True), json.dumps({disk: config[disk]}, sort_keys=True),
) )
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="lxc-resize",
payload={"resource_id": str(resource["id"]), "disk": disk, "size": size},
)
async def lxc_template(request: Request, inputs: dict[str, Any]) -> None: async def lxc_template(request: Request, inputs: dict[str, Any]) -> None:
payload = _values(inputs) payload = _values(inputs)
@@ -537,6 +637,8 @@ async def _create_task(
"lxc-clone": "pctclone", "lxc-clone": "pctclone",
"lxc-migrate": "pctmigrate", "lxc-migrate": "pctmigrate",
"lxc-remote-migrate": "pctremote", "lxc-remote-migrate": "pctremote",
"lxc-resize": "pctresize",
"lxc-move-volume": "pctmove",
}[task_type] }[task_type]
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal))) upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
try: try:
+15 -2
View File
@@ -43,8 +43,11 @@ def register_lxc_extra_handlers(registry: HandlerRegistry) -> None:
return [dict(item) for item in ifaces] if isinstance(ifaces, list) else [] return [dict(item) for item in ifaces] if isinstance(ifaces, list) else []
async def move_volume(request: Request, inputs: dict[str, Any]) -> str: async def move_volume(request: Request, inputs: dict[str, Any]) -> str:
from app.handlers.lxc import _create_task
values = _values(inputs) values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"])) node, vmid = str(values["node"]), str(values["vmid"])
resource = await _lxc_resource(request, node, vmid)
volume = str(values.get("volume") or values.get("disk") or "rootfs") volume = str(values.get("volume") or values.get("disk") or "rootfs")
storage = str(values.get("storage") or "local-lvm") storage = str(values.get("storage") or "local-lvm")
config = _state(resource["config"]) config = _state(resource["config"])
@@ -60,7 +63,17 @@ def register_lxc_extra_handlers(registry: HandlerRegistry) -> None:
moves = state["volume_moves"] = [] moves = state["volume_moves"] = []
moves.append({"volume": volume, "storage": storage}) moves.append({"volume": volume, "storage": storage})
await _save_state(request, resource["id"], state) await _save_state(request, resource["id"], state)
return f"UPID:{values['node']}:lxc-move-volume:{values['vmid']}" return await _create_task(
request,
node=node,
vmid=vmid,
task_type="lxc-move-volume",
payload={
"resource_id": str(resource["id"]),
"volume": volume,
"storage": storage,
},
)
async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs) values = _values(inputs)
+105 -16
View File
@@ -43,13 +43,59 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
ops = await load_node_ops(request, node) ops = await load_node_ops(request, node)
packages = ops.get("apt", {}).get("packages", []) packages = ops.get("apt", {}).get("packages", [])
return list(packages) if isinstance(packages, list) else [] if not isinstance(packages, list):
return []
result: list[dict[str, Any]] = []
for item in packages:
if not isinstance(item, dict):
continue
entry = dict(item)
status = str(entry.pop("Status", entry.get("CurrentState", "Installed")))
if status.lower() == "upgradable":
entry["CurrentState"] = "Installed"
entry.setdefault("OldVersion", entry.get("Version", ""))
else:
entry["CurrentState"] = "Installed" if status.lower() == "installed" else status
entry.setdefault("Package", entry.get("Package") or "unknown")
entry.setdefault("Version", entry.get("Version") or "")
entry.setdefault("Arch", "amd64")
entry.setdefault("Origin", "Proxmox")
result.append(entry)
return result
async def apt_repositories(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def apt_repositories(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
ops = await load_node_ops(request, node) ops = await load_node_ops(request, node)
repositories = ops.get("apt", {}).get("repositories", []) repositories = ops.get("apt", {}).get("repositories")
return list(repositories) if isinstance(repositories, list) else [] if isinstance(repositories, dict) and "files" in repositories:
return {
"digest": str(repositories.get("digest") or "seedapt"),
"files": list(repositories.get("files") or []),
"errors": list(repositories.get("errors") or []),
"infos": list(repositories.get("infos") or []),
"standard-repos": list(repositories.get("standard-repos") or []),
}
legacy = list(repositories) if isinstance(repositories, list) else []
return {
"digest": "seedapt",
"files": [
{
"path": "/etc/apt/sources.list.d/pve-enterprise.list",
"file-type": "list",
"digest": "seedapt-file0",
"repositories": legacy,
}
],
"errors": [],
"infos": [],
"standard-repos": [
{
"handle": "no-subscription",
"name": "Proxmox VE No-Subscription Repository",
"status": 1,
}
],
}
async def apt_changelog(request: Request, inputs: dict[str, Any]) -> str: async def apt_changelog(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
@@ -89,7 +135,7 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
return item return item
raise ApiError(404, "interface does not exist") raise ApiError(404, "interface does not exist")
async def network_mutate(request: Request, inputs: dict[str, Any]) -> None: async def network_mutate(request: Request, inputs: dict[str, Any]) -> str | None:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
payload = values(inputs) payload = values(inputs)
ops = await load_node_ops(request, node) ops = await load_node_ops(request, node)
@@ -140,8 +186,14 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
raise ApiError(404, "interface does not exist") raise ApiError(404, "interface does not exist")
ops["network"] = updated ops["network"] = updated
else: else:
# PUT /nodes/{node}/network without iface → reload (returns UPID string).
ops["network_applied"] = True ops["network_applied"] = True
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
return await _node_task(
request, node=node, task_type="network-reload", worker="srvreload"
)
await save_node_ops(request, node, ops)
return None
async def disks_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]: async def disks_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"])) await require_node(request, str(values(inputs)["node"]))
@@ -189,7 +241,7 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
async def disks_zfs(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def disks_zfs(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
return await disks_collection(request, inputs, "zfs") return await disks_collection(request, inputs, "zfs")
async def disks_initgpt(request: Request, inputs: dict[str, Any]) -> None: async def disks_initgpt(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "") disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
if not disk: if not disk:
@@ -219,8 +271,9 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
disks["list"] = items disks["list"] = items
ops["disks"] = disks ops["disks"] = disks
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
return await _node_task(request, node=node, task_type="disk-initgpt", worker="diskinit")
async def disks_wipedisk(request: Request, inputs: dict[str, Any]) -> None: async def disks_wipedisk(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "") disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
if not disk: if not disk:
@@ -243,14 +296,30 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
smart.pop(disk, None) smart.pop(disk, None)
ops["disks"] = disks ops["disks"] = disks
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
return await _node_task(request, node=node, task_type="disk-wipe", worker="diskwipe")
async def services_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]: async def services_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
ops = await load_node_ops(request, node) ops = await load_node_ops(request, node)
services = ops.get("services") or {} services = ops.get("services") or {}
return [{"subdir": name} for name in sorted(services)] result: list[dict[str, Any]] = []
for name in sorted(services):
payload = dict(services[name]) if isinstance(services[name], dict) else {}
state_name = str(payload.get("state", "stopped"))
enabled = bool(int(payload.get("enabled", 0) or 0))
result.append(
{
"service": name,
"name": f"{name}.service",
"desc": str(payload.get("desc") or name),
"state": state_name,
"active-state": "active" if state_name == "running" else "inactive",
"unit-state": "enabled" if enabled else "disabled",
}
)
return result
async def service_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def service_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
service = str(values(inputs)["service"]) service = str(values(inputs)["service"])
ops = await load_node_ops(request, node) ops = await load_node_ops(request, node)
@@ -258,12 +327,25 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
if service not in services: if service not in services:
services[service] = {"state": "stopped", "enabled": 0} services[service] = {"state": "stopped", "enabled": 0}
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
payload = dict(services[service]) return subdirs("state", "start", "stop", "restart", "reload")
payload["service"] = service
return payload
async def service_state(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def service_state(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await service_get(request, inputs) 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]) if isinstance(services[service], dict) else {}
state_name = str(payload.get("state", "stopped"))
enabled = bool(int(payload.get("enabled", 0) or 0))
return {
"service": service,
"state": state_name,
"active-state": "active" if state_name == "running" else "inactive",
"unit-state": "enabled" if enabled else "disabled",
}
async def service_action(request: Request, inputs: dict[str, Any]) -> str: async def service_action(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
@@ -286,7 +368,12 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
services[service] = current services[service] = current
ops["services"] = services ops["services"] = services
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
return "OK" return await _node_task(
request,
node=node,
task_type=f"service-{action}",
worker=f"srv{action}",
)
registry.register("/nodes/{node}/apt", "GET", apt_index) registry.register("/nodes/{node}/apt", "GET", apt_index)
registry.register("/nodes/{node}/apt/versions", "GET", apt_versions) registry.register("/nodes/{node}/apt/versions", "GET", apt_versions)
@@ -319,6 +406,8 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str: async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str:
import secrets
from app.api.errors import ApiError from app.api.errors import ApiError
from app.db.primitives import ConflictError from app.db.primitives import ConflictError
@@ -329,7 +418,7 @@ async def _node_task(request: Request, *, node: str, task_type: str, worker: str
upid=upid, upid=upid,
task_type=task_type, task_type=task_type,
payload={"node": node}, payload={"node": node},
resource_key=f"node:{node}", resource_key=f"node:{node}:{task_type}:{secrets.token_hex(4)}",
) )
except ConflictError as error: except ConflictError as error:
raise ApiError(409, str(error)) from error raise ApiError(409, str(error)) from error
+185 -47
View File
@@ -108,7 +108,9 @@ async def _migrate_guests(request: Request, node: str, target: str) -> None:
def register_nodes_extra_handlers(registry: HandlerRegistry) -> None: def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
async def disks_create(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]: async def disks_create(request: Request, inputs: dict[str, Any], kind: str) -> str:
from app.handlers.nodes import _node_task
payload = values(inputs) payload = values(inputs)
node = str(payload["node"]) node = str(payload["node"])
await require_node(request, node) await require_node(request, node)
@@ -134,9 +136,13 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
ops["disks"] = disks ops["disks"] = disks
disks[kind] = items disks[kind] = items
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
return entry return await _node_task(
request, node=node, task_type=f"disk-{kind}-create", worker=f"disk{kind}"
)
async def disks_delete(request: Request, inputs: dict[str, Any], kind: str) -> str:
from app.handlers.nodes import _node_task
async def disks_delete(request: Request, inputs: dict[str, Any], kind: str) -> None:
payload = values(inputs) payload = values(inputs)
node = str(payload["node"]) node = str(payload["node"])
name = str(payload["name"]) name = str(payload["name"])
@@ -148,6 +154,9 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
raise ApiError(404, f"{kind} does not exist") raise ApiError(404, f"{kind} does not exist")
ops.setdefault("disks", {})[kind] = remaining ops.setdefault("disks", {})[kind] = remaining
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
return await _node_task(
request, node=node, task_type=f"disk-{kind}-delete", worker=f"diskdel{kind}"
)
async def disks_zfs_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def disks_zfs_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs) payload = values(inputs)
@@ -194,11 +203,11 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
certs["acme"] = acme certs["acme"] = acme
ops["certificates"] = certs ops["certificates"] = certs
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
if method == "DELETE":
return None
return await _node_task(request, node=node, task_type="acme", worker="acme") return await _node_task(request, node=node, task_type="acme", worker="acme")
async def certificates_custom(request: Request, inputs: dict[str, Any]) -> None: async def certificates_custom(
request: Request, inputs: dict[str, Any]
) -> dict[str, Any] | None:
payload = values(inputs) payload = values(inputs)
node = str(payload["node"]) node = str(payload["node"])
await require_node(request, node) await require_node(request, node)
@@ -206,23 +215,18 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
certs = _certificates(ops) certs = _certificates(ops)
if request.method.upper() == "DELETE": if request.method.upper() == "DELETE":
certs["custom"] = None certs["custom"] = None
else: ops["certificates"] = certs
await save_node_ops(request, node, ops)
return None
certificates = str(payload.get("certificates") or payload.get("cert") or "") certificates = str(payload.get("certificates") or payload.get("cert") or "")
if not certificates: if not certificates:
raise ApiError(400, "parameter verification failed - 'certificates' missing") raise ApiError(400, "parameter verification failed - 'certificates' missing")
key = str(payload.get("key") or payload.get("private-key") or "") key = str(payload.get("key") or payload.get("private-key") or "")
certs["custom"] = { filename = str(payload.get("filename") or "pveproxy-ssl.pem")
"certificates": certificates, fingerprint = ":".join(secrets.token_hex(1) for _ in range(32)) # SHA-256 fingerprint style
"key": key, summary = {
"restart": int(payload.get("restart") or 0), "filename": filename,
"filename": str(payload.get("filename") or "pveproxy-ssl.pem"), "fingerprint": fingerprint,
}
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", "issuer": "CN=Simulator",
"subject": "CN=pve.local", "subject": "CN=pve.local",
"notbefore": int(time.time()) - 86_400, "notbefore": int(time.time()) - 86_400,
@@ -230,11 +234,21 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
"san": ["DNS:pve.local"], "san": ["DNS:pve.local"],
"public-key-type": "rsa", "public-key-type": "rsa",
"public-key-bits": 2048, "public-key-bits": 2048,
"pem": certificates,
} }
) certs["custom"] = {
"certificates": certificates,
"key": key,
"restart": int(payload.get("restart") or 0),
"filename": filename,
}
info = list(certs.get("info") or [])
info = [item for item in info if item.get("filename") != filename]
info.append({key: value for key, value in summary.items() if key != "pem"})
certs["info"] = info certs["info"] = info
ops["certificates"] = certs ops["certificates"] = certs
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
return summary
async def certificates_info(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def certificates_info(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
@@ -257,7 +271,39 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
certs["info"] = info certs["info"] = info
ops["certificates"] = certs ops["certificates"] = certs
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
return [dict(item) for item in info] if not info:
from app.simulation.seed import _seed_fingerprint
info = [
{
"filename": "pve-ssl.pem",
"fingerprint": _seed_fingerprint(node),
"issuer": f"CN={node}.proxmox.local",
"subject": f"CN={node}.proxmox.local",
"notbefore": 1_700_000_000,
"notafter": 2_000_000_000,
"pem": (
"-----BEGIN CERTIFICATE-----\n"
"SIMULATOR-SEED-CERT\n"
"-----END CERTIFICATE-----\n"
),
"san": [f"{node}.proxmox.local"],
"public-key-type": "rsa",
"public-key-bits": 4096,
}
]
certs["info"] = info
ops["certificates"] = certs
await save_node_ops(request, node, ops)
result: list[dict[str, Any]] = []
for item in info:
entry = dict(item)
entry.setdefault("pem", "-----BEGIN CERTIFICATE-----\nSIM\n-----END CERTIFICATE-----\n")
entry.setdefault("san", [str(entry.get("subject", "CN=pve.local")).removeprefix("CN=")])
entry.setdefault("public-key-type", "rsa")
entry.setdefault("public-key-bits", 4096)
result.append(entry)
return result
async def scan_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]: async def scan_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"])) await require_node(request, str(values(inputs)["node"]))
@@ -437,44 +483,136 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
if not isinstance(apt, dict): if not isinstance(apt, dict):
apt = {} apt = {}
ops["apt"] = apt ops["apt"] = apt
repositories = list(apt.get("repositories") or []) envelope = apt.get("repositories")
if isinstance(envelope, dict) and "files" in envelope:
files = [
dict(item) for item in list(envelope.get("files") or []) if isinstance(item, dict)
]
standard = [
dict(item)
for item in list(envelope.get("standard-repos") or [])
if isinstance(item, dict)
]
else:
legacy = list(envelope) if isinstance(envelope, list) else []
files = [
{
"path": "/etc/apt/sources.list.d/pve-enterprise.list",
"file-type": "list",
"digest": "seedapt-file0",
"repositories": legacy,
}
]
standard = []
method = request.method.upper() method = request.method.upper()
if method == "POST": handle = payload.get("handle")
entry = { if handle is not None:
updated_standard: list[dict[str, Any]] = []
found = False
for item in standard:
if item.get("handle") == handle:
found = True
merged = dict(item)
if "enabled" in payload:
merged["status"] = int(bool(payload.get("enabled")))
updated_standard.append(merged)
else:
updated_standard.append(item)
if not found:
updated_standard.append(
{
"handle": str(handle),
"name": str(payload.get("name") or handle),
"status": int(bool(payload.get("enabled", 1))),
}
)
standard = updated_standard
elif method == "POST":
repo_entry = {
key: value key: value
for key, value in payload.items() for key, value in payload.items()
if key not in {"node", "delete", "digest"} if key not in {"node", "delete", "digest", "path", "handle", "index", "file-type"}
} }
entry.setdefault("path", f"/etc/apt/sources.list.d/sim-{secrets.token_hex(2)}.list") path = str(
entry.setdefault("enabled", 1) payload.get("path") or f"/etc/apt/sources.list.d/sim-{secrets.token_hex(2)}.list"
repositories.append(entry) )
matched = False
for file_entry in files:
if file_entry.get("path") == path:
repos = list(file_entry.get("repositories") or [])
repos.append(repo_entry)
file_entry["repositories"] = repos
matched = True
break
if not matched:
files.append(
{
"path": path,
"file-type": str(payload.get("file-type") or "list"),
"digest": secrets.token_hex(4),
"repositories": [repo_entry],
}
)
else: else:
path = payload.get("path") path = payload.get("path")
handle = payload.get("handle")
index = payload.get("index") index = payload.get("index")
updated: list[dict[str, Any]] = [] for file_idx, file_entry in enumerate(files):
for idx, item in enumerate(repositories): if path is not None and file_entry.get("path") != path:
match = False continue
if path is not None and item.get("path") == path: repos = [
match = True dict(item)
if handle is not None and item.get("handle") == handle: for item in list(file_entry.get("repositories") or [])
match = True if isinstance(item, dict)
if index is not None and idx == int(index): ]
match = True if index is not None:
if match or (path is None and handle is None and index is None and idx == 0): idx = int(index)
merged = { if 0 <= idx < len(repos):
**item, repos[idx] = {
**repos[idx],
**{ **{
key: value key: value
for key, value in payload.items() for key, value in payload.items()
if key not in {"node", "delete", "digest", "path", "handle", "index"} if key
not in {
"node",
"delete",
"digest",
"path",
"handle",
"index",
"file-type",
}
}, },
} }
updated.append(merged) elif path is not None or file_idx == 0:
else: if repos:
updated.append(item) repos[0] = {
repositories = updated **repos[0],
apt["repositories"] = repositories **{
key: value
for key, value in payload.items()
if key
not in {
"node",
"delete",
"digest",
"path",
"handle",
"index",
"file-type",
}
},
}
file_entry["repositories"] = repos
if path is not None:
break
apt["repositories"] = {
"digest": secrets.token_hex(4),
"errors": [],
"infos": [],
"standard-repos": standard,
"files": files,
}
ops["apt"] = apt ops["apt"] = apt
await save_node_ops(request, node, ops) await save_node_ops(request, node, ops)
+35 -9
View File
@@ -14,14 +14,31 @@ from app.handlers.common import database, require_value, state, values
from app.simulation.seed import CLUSTER_ID, stable_id from app.simulation.seed import CLUSTER_ID, stable_id
async def _pool_members(request: Request, pool_id: uuid.UUID) -> list[str]: async def _pool_members(request: Request, pool_id: uuid.UUID) -> list[dict[str, Any]]:
rows = await database(request).pool.fetch( rows = await database(request).pool.fetch(
"""SELECT r.external_id FROM pool_members pm """SELECT r.external_id, r.kind, r.state, n.name AS node
FROM pool_members pm
JOIN resources r ON r.id = pm.resource_id JOIN resources r ON r.id = pm.resource_id
WHERE pm.pool_id=$1 ORDER BY r.external_id::integer""", JOIN nodes n ON n.id = r.node_id
WHERE pm.pool_id=$1
ORDER BY r.external_id::integer""",
pool_id, pool_id,
) )
return [str(row["external_id"]) for row in rows] members: list[dict[str, Any]] = []
for row in rows:
guest_state = state(row["state"])
kind = str(row["kind"])
vmid = int(row["external_id"])
members.append(
{
"id": f"{kind}/{vmid}",
"node": str(row["node"]),
"type": kind,
"vmid": vmid,
"name": str(guest_state.get("name") or f"{kind}-{vmid}"),
}
)
return members
def register_pool_handlers(registry: HandlerRegistry) -> None: def register_pool_handlers(registry: HandlerRegistry) -> None:
@@ -39,16 +56,25 @@ def register_pool_handlers(registry: HandlerRegistry) -> None:
metadata = state(row["metadata"]) metadata = state(row["metadata"])
members = await _pool_members(request, row["id"]) members = await _pool_members(request, row["id"])
if not members and isinstance(metadata.get("members"), list): if not members and isinstance(metadata.get("members"), list):
members = [str(item) for item in metadata["members"]] # Fallback for metadata-only membership before pool_members rows exist.
item: dict[str, Any] = { for item in metadata["members"]:
members.append(
{
"id": f"qemu/{item}",
"node": "",
"type": "qemu",
"vmid": int(item) if str(item).isdigit() else 0,
}
)
pool_item: dict[str, Any] = {
"poolid": str(row["pool_id"]), "poolid": str(row["pool_id"]),
"members": members, "members": members,
} }
if row["comment"] is not None: if row["comment"] is not None:
item["comment"] = str(row["comment"]) pool_item["comment"] = str(row["comment"])
elif metadata.get("comment"): elif metadata.get("comment"):
item["comment"] = str(metadata["comment"]) pool_item["comment"] = str(metadata["comment"])
result.append(item) result.append(pool_item)
return result return result
async def pool_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def pool_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
+119 -28
View File
@@ -65,6 +65,8 @@ def _agent_config_string(value: object) -> str:
def _public_qemu_config(config: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]: def _public_qemu_config(config: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
"""Wire-format QEMU config for clients (bpg / pulumi-proxmoxve).""" """Wire-format QEMU config for clients (bpg / pulumi-proxmoxve)."""
import hashlib
merged = {**state, **config} merged = {**state, **config}
payload: dict[str, Any] = {} payload: dict[str, Any] = {}
for key, value in merged.items(): for key, value in merged.items():
@@ -75,19 +77,66 @@ def _public_qemu_config(config: dict[str, Any], state: dict[str, Any]) -> dict[s
payload[key] = value payload[key] = value
agent = config.get("agent", state.get("agent", "0")) agent = config.get("agent", state.get("agent", "0"))
payload["agent"] = _agent_config_string(agent) payload["agent"] = _agent_config_string(agent)
digest_src = {key: value for key, value in payload.items() if key != "digest"}
payload["digest"] = hashlib.sha1(
json.dumps(digest_src, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
return payload return payload
def _qemu_index_item(vmid: int, vm_state: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
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 = int(vm_state.get("mem", maxmem // 2 if running else 0))
return {
"vmid": vmid,
"name": str(config.get("name", f"vm-{vmid}")),
"status": status,
"qmpstatus": status if running else "stopped",
"cpus": int(config.get("cores", config.get("cpus", 1))),
"cpu": float(vm_state.get("cpu", 0.1 if running else 0.0)),
"maxmem": maxmem,
"mem": mem,
"memhost": int(vm_state.get("memhost", mem)),
"maxdisk": int(vm_state.get("maxdisk", config.get("maxdisk", 32 * 1024**3))),
"disk": int(vm_state.get("disk", 0)),
"diskread": int(vm_state.get("diskread", 0)),
"diskwrite": int(vm_state.get("diskwrite", 0)),
"netin": int(vm_state.get("netin", 0)),
"netout": int(vm_state.get("netout", 0)),
"uptime": int(vm_state.get("uptime", 0)) if running else 0,
"pid": int(vm_state.get("pid", 12_345 if running else 0)),
"lock": str(vm_state.get("lock", "")),
"tags": str(config.get("tags", "")),
"template": int(bool(vm_state.get("template", False))),
"running-qemu": str(vm_state.get("running-qemu", "9.2.0")) if running else "",
"running-machine": str(vm_state.get("running-machine", "pc-i440fx-9.2")) if running else "",
"pressurecpusome": float(vm_state.get("pressurecpusome", 0.0)),
"pressurecpufull": float(vm_state.get("pressurecpufull", 0.0)),
"pressureiosome": float(vm_state.get("pressureiosome", 0.0)),
"pressureiofull": float(vm_state.get("pressureiofull", 0.0)),
"pressurememorysome": float(vm_state.get("pressurememorysome", 0.0)),
"pressurememoryfull": float(vm_state.get("pressurememoryfull", 0.0)),
}
def register_qemu_handlers(registry: HandlerRegistry) -> None: def register_qemu_handlers(registry: HandlerRegistry) -> None:
async def qemu_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def qemu_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(_values(inputs)["node"]) node = str(_values(inputs)["node"])
rows = await _database(request).pool.fetch( rows = await _database(request).pool.fetch(
"""SELECT r.external_id::integer AS vmid, r.state """SELECT r.external_id::integer AS vmid, r.state, v.config
FROM resources r JOIN nodes n ON n.id=r.node_id 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' ORDER BY r.external_id::integer""", WHERE n.name=$1 AND r.kind='qemu' ORDER BY r.external_id::integer""",
node, node,
) )
return [{"vmid": int(row["vmid"]), **_state(row["state"])} for row in rows] return [
_qemu_index_item(int(row["vmid"]), _state(row["state"]), _state(row["config"]))
for row in rows
]
async def qemu_status_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]: async def qemu_status_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs) payload = _values(inputs)
@@ -127,15 +176,11 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
else 0, else 0,
) )
) )
return { agent_enabled = str(config.get("agent", "0")).startswith("1")
"vmid": vmid, item = _qemu_index_item(vmid, vm_state, config)
"name": str(config.get("name", f"vm-{vmid}")), item.update(
"status": status, {
"qmpstatus": status if running else "stopped", "uptime": uptime,
"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, "mem": mem_used,
"balloon": int(vm_state.get("balloon", 0)), "balloon": int(vm_state.get("balloon", 0)),
"ballooninfo": { "ballooninfo": {
@@ -144,11 +189,15 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
"mem_swapped_in": 0, "mem_swapped_in": 0,
"mem_swapped_out": 0, "mem_swapped_out": 0,
}, },
"uptime": uptime, "template": bool(vm_state.get("template", False)),
"template": int(bool(vm_state.get("template", False))),
"ha": {"managed": int(vm_state.get("ha_managed", 0))}, "ha": {"managed": int(vm_state.get("ha_managed", 0))},
"agent": 1 if running and str(config.get("agent", "0")).startswith("1") else 0, "agent": bool(running and agent_enabled),
"clipboard": str(vm_state.get("clipboard", "")),
"spice": bool(vm_state.get("spice", False)),
"serial": bool(vm_state.get("serial", False)),
} }
)
return item
async def qemu_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def qemu_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"]) node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
@@ -339,24 +388,30 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
values = _values(inputs) values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"])) resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
rows = await _database(request).pool.fetch( rows = await _database(request).pool.fetch(
"""SELECT name, parent_name, description, created_at FROM snapshots """SELECT name, parent_name, description, created_at, state FROM snapshots
WHERE resource_id=$1 ORDER BY created_at, name""", WHERE resource_id=$1 ORDER BY created_at, name""",
resource["id"], resource["id"],
) )
return [ result: list[dict[str, Any]] = []
for row in rows:
raw_state = row["state"] if "state" in row else None
snap_state = _state(raw_state) if raw_state is not None else {}
result.append(
{ {
"name": row["name"], "name": row["name"],
"parent": row["parent_name"], "parent": row["parent_name"],
"description": row["description"] or "", "description": row["description"] or "",
"snaptime": int(row["created_at"].timestamp()), "snaptime": int(row["created_at"].timestamp()),
"vmstate": int(bool(snap_state.get("vmstate"))),
} }
for row in rows )
] return result
async def snapshot_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def snapshot_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs) values = _values(inputs)
row = await _snapshot(request, values) row = await _snapshot(request, values)
state = _state(row["state"]) raw_state = row["state"] if "state" in row else None
state = _state(raw_state) if raw_state is not None else {}
return { return {
"name": row["name"], "name": row["name"],
"parent": row["parent_name"], "parent": row["parent_name"],
@@ -528,7 +583,7 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
}, },
) )
async def resize(request: Request, inputs: dict[str, Any]) -> None: async def resize(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs) values = _values(inputs)
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"]) node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
resource = await _qemu_resource(request, node, vmid) resource = await _qemu_resource(request, node, vmid)
@@ -561,6 +616,13 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
str(config[disk]).split(":", 1)[0], str(config[disk]).split(":", 1)[0],
size, size,
) )
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-resize",
payload={"resource_id": str(resource["id"]), "disk": disk, "size": size},
)
async def move_disk(request: Request, inputs: dict[str, Any]) -> str: async def move_disk(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs) values = _values(inputs)
@@ -635,12 +697,32 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
return await agent_result("ping", request, inputs) return await agent_result("ping", request, inputs)
async def task_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]: async def task_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
tasks = await TaskRepository(_database(request).pool).list_for_node( from app.tasks.upid import Upid
str(_values(inputs)["node"])
) node = str(_values(inputs)["node"])
return [ tasks = await TaskRepository(_database(request).pool).list_for_node(node)
{"upid": task.upid, "status": task.status, "type": task.task_type} for task in tasks result: list[dict[str, Any]] = []
] for task in tasks:
try:
parsed = Upid.parse(task.upid)
except ValueError:
continue
finished = task.status in {"success", "error", "cancelled"}
entry: dict[str, Any] = {
"upid": task.upid,
"id": parsed.task_id,
"node": parsed.node,
"pid": parsed.pid,
"pstart": parsed.process_start,
"starttime": parsed.start_time,
"type": parsed.task_type,
"user": parsed.user,
}
if finished:
entry["status"] = "OK" if task.status == "success" else str(task.status).upper()
entry["endtime"] = parsed.start_time + max(task.progress, 1)
result.append(entry)
return result
async def task_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def task_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
task = await TaskRepository(_database(request).pool).get_by_upid( task = await TaskRepository(_database(request).pool).get_by_upid(
@@ -681,7 +763,7 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
} }
} }
async def qemu_template(request: Request, inputs: dict[str, Any]) -> None: async def qemu_template(request: Request, inputs: dict[str, Any]) -> str:
payload = _values(inputs) payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"]) node, vmid = str(payload["node"]), str(payload["vmid"])
resource = await _qemu_resource(request, node, vmid) resource = await _qemu_resource(request, node, vmid)
@@ -698,6 +780,13 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
resource["id"], resource["id"],
json.dumps(state, sort_keys=True), json.dumps(state, sort_keys=True),
) )
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-template",
payload={"resource_id": str(resource["id"])},
)
async def qemu_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]: async def qemu_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs) payload = _values(inputs)
@@ -815,6 +904,8 @@ async def _create_task(
"qemu-migrate": "qmigrate", "qemu-migrate": "qmigrate",
"qemu-remote-migrate": "qmremote", "qemu-remote-migrate": "qmremote",
"qemu-move-disk": "qmmove", "qemu-move-disk": "qmmove",
"qemu-resize": "qmresize",
"qemu-template": "qmtemplate",
}[task_type] }[task_type]
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal))) upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
try: try:
+57 -3
View File
@@ -25,11 +25,62 @@ def _sdn(metadata: dict[str, Any]) -> dict[str, Any]:
return current if isinstance(current, dict) else {} return current if isinstance(current, dict) else {}
_VNET_LIST_KEYS = frozenset(
{
"vnet",
"type",
"zone",
"alias",
"tag",
"vlanaware",
"digest",
"isolate-ports",
"pending",
"state",
}
)
_ZONE_LIST_KEYS = frozenset(
{
"zone",
"type",
"bridge",
"mtu",
"nodes",
"ipam",
"dns",
"reversedns",
"vlan-protocol",
"digest",
"pending",
"state",
"comment",
}
)
def _public(item: dict[str, Any]) -> dict[str, Any]: def _public(item: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in item.items() if key not in _SECRET_KEYS} return {key: value for key, value in item.items() if key not in _SECRET_KEYS}
def _vnet_list_item(name: str, item: dict[str, Any]) -> dict[str, Any]:
payload = {key: value for key, value in item.items() if key in _VNET_LIST_KEYS}
payload["vnet"] = str(payload.get("vnet") or name)
payload.setdefault("type", "vnet")
return _public(payload)
def _zone_list_item(name: str, item: dict[str, Any]) -> dict[str, Any]:
payload = {key: value for key, value in item.items() if key in _ZONE_LIST_KEYS}
payload["zone"] = str(payload.get("zone") or name)
return _public(payload)
def _store_list(store: dict[str, Any], *, id_key: str) -> list[dict[str, Any]]: def _store_list(store: dict[str, Any], *, id_key: str) -> list[dict[str, Any]]:
if id_key == "vnet":
return [_vnet_list_item(name, item) for name, item in sorted(store.items())]
if id_key == "zone":
return [_zone_list_item(name, item) for name, item in sorted(store.items())]
return [_public({id_key: name, **item}) for name, item in sorted(store.items())] return [_public({id_key: name, **item}) for name, item in sorted(store.items())]
@@ -59,7 +110,9 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
"zones", "zones",
) )
async def apply(request: Request, inputs: dict[str, Any]) -> None: async def apply(request: Request, inputs: dict[str, Any]) -> str:
from app.handlers.cluster_extra import _cluster_task
payload = values(inputs) payload = values(inputs)
metadata, sdn = await _load(request, for_write=True) metadata, sdn = await _load(request, for_write=True)
lock = sdn.get("lock") lock = sdn.get("lock")
@@ -71,15 +124,16 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
if payload.get("release-lock"): if payload.get("release-lock"):
sdn["lock"] = None sdn["lock"] = None
await save_cluster_metadata(request, metadata) await save_cluster_metadata(request, metadata)
return await _cluster_task(request, task_type="sdn-apply", worker="sdnapply")
async def lock_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def lock_create(request: Request, inputs: dict[str, Any]) -> str:
metadata, sdn = await _load(request, for_write=True) metadata, sdn = await _load(request, for_write=True)
if sdn.get("lock") and not values(inputs).get("allow-pending"): if sdn.get("lock") and not values(inputs).get("allow-pending"):
raise ApiError(400, "SDN is already locked") raise ApiError(400, "SDN is already locked")
token = secrets.token_hex(8) token = secrets.token_hex(8)
sdn["lock"] = {"token": token} sdn["lock"] = {"token": token}
await save_cluster_metadata(request, metadata) await save_cluster_metadata(request, metadata)
return {"digest": token, "token": token} return token
async def lock_delete(request: Request, inputs: dict[str, Any]) -> None: async def lock_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs) payload = values(inputs)
+58 -5
View File
@@ -148,6 +148,7 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
JOIN resources r ON r.id = s.resource_id JOIN resources r ON r.id = s.resource_id
JOIN nodes n ON n.id = r.node_id JOIN nodes n ON n.id = r.node_id
WHERE n.name=$1 OR s.shared = true WHERE n.name=$1 OR s.shared = true
OR s.storage_id IN ('local', 'local-lvm')
ORDER BY s.storage_id""", ORDER BY s.storage_id""",
node, node,
) )
@@ -294,7 +295,55 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
if status != "DELETE 1": if status != "DELETE 1":
raise ApiError(404, "volume does not exist") raise ApiError(404, "volume does not exist")
async def node_storage_upload(request: Request, inputs: dict[str, Any]) -> dict[str, Any]: async def node_storage_allocate(request: Request, inputs: dict[str, Any]) -> str:
from app.handlers.nodes import _node_task
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 f"vm-{payload.get('vmid', '0')}-disk-0")
content_type = str(payload.get("content") or "images")
size_raw = str(payload.get("size") or "1G")
# Accept either integer bytes or Proxmox size strings like 32G.
try:
size = int(size_raw)
except ValueError:
from app.handlers.common import parse_size_bytes
try:
size = parse_size_bytes(size_raw)
except ValueError as error:
raise ApiError(400, f"invalid size '{size_raw}'") from error
format_name = str(payload.get("format") or "raw")
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, "format": format_name, "source": "allocate"},
sort_keys=True,
),
)
return await _node_task(request, node=node, task_type="storage-allocate", worker="imgcopy")
async def node_storage_upload(request: Request, inputs: dict[str, Any]) -> str:
from app.handlers.nodes import _node_task
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"]) storage_id = str(values(inputs)["storage"])
await require_node(request, node) await require_node(request, node)
@@ -326,11 +375,11 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
size, size,
json.dumps({"filename": filename, "source": "upload"}, sort_keys=True), json.dumps({"filename": filename, "source": "upload"}, sort_keys=True),
) )
return {"uploadid": volume_id, "filename": filename, "size": size, "volid": volume_id} return await _node_task(request, node=node, task_type="storage-upload", worker="download")
async def node_storage_prunebackups( async def node_storage_prunebackups(
request: Request, inputs: dict[str, Any] request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]] | None: ) -> list[dict[str, Any]] | str:
node = str(values(inputs)["node"]) node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"]) storage_id = str(values(inputs)["storage"])
await require_node(request, node) await require_node(request, node)
@@ -340,6 +389,8 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
storage_id, storage_id,
) )
if request.method == "DELETE": if request.method == "DELETE":
from app.handlers.nodes import _node_task
keep = int(values(inputs).get("keep-last") or values(inputs).get("keep_last") or 1) keep = int(values(inputs).get("keep-last") or values(inputs).get("keep_last") or 1)
await database(request).pool.execute( await database(request).pool.execute(
"""DELETE FROM backups """DELETE FROM backups
@@ -352,7 +403,9 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
resource_id, resource_id,
keep, keep,
) )
return None return await _node_task(
request, node=node, task_type="prune-backups", worker="prunebackups"
)
rows = await database(request).pool.fetch( rows = await database(request).pool.fetch(
"""SELECT volume_id, size_bytes, created_at FROM backups """SELECT volume_id, size_bytes, created_at FROM backups
WHERE storage_resource_id=$1 ORDER BY created_at DESC""", WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
@@ -578,7 +631,7 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
registry.register("/nodes/{node}/storage/{storage}", "GET", node_storage_index) 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}/status", "GET", node_storage_status)
registry.register("/nodes/{node}/storage/{storage}/content", "GET", node_storage_content) 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", "POST", node_storage_allocate)
registry.register( registry.register(
"/nodes/{node}/storage/{storage}/content/{volume}", "GET", node_storage_content_get "/nodes/{node}/storage/{storage}/content/{volume}", "GET", node_storage_content_get
) )
+26
View File
@@ -68,12 +68,15 @@ def create_app(
"qemu-stop": qemu, "qemu-stop": qemu,
"qemu-suspend": qemu, "qemu-suspend": qemu,
"qemu-update": qemu, "qemu-update": qemu,
"qemu-resize": qemu,
"qemu-template": qemu,
"lxc-clone": lxc, "lxc-clone": lxc,
"lxc-create": lxc, "lxc-create": lxc,
"lxc-delete": lxc, "lxc-delete": lxc,
"lxc-migrate": lxc, "lxc-migrate": lxc,
"lxc-reboot": lxc, "lxc-reboot": lxc,
"lxc-resume": lxc, "lxc-resume": lxc,
"lxc-resize": lxc,
"lxc-shutdown": lxc, "lxc-shutdown": lxc,
"lxc-snapshot-create": lxc, "lxc-snapshot-create": lxc,
"lxc-snapshot-delete": lxc, "lxc-snapshot-delete": lxc,
@@ -83,6 +86,29 @@ def create_app(
"lxc-suspend": lxc, "lxc-suspend": lxc,
"vzdump": backup, "vzdump": backup,
"aptupdate": backup, "aptupdate": backup,
"network-reload": backup,
"disk-initgpt": backup,
"disk-wipe": backup,
"disk-directory-create": backup,
"disk-directory-delete": backup,
"disk-lvm-create": backup,
"disk-lvm-delete": backup,
"disk-lvmthin-create": backup,
"disk-lvmthin-delete": backup,
"disk-zfs-create": backup,
"disk-zfs-delete": backup,
"service-start": backup,
"service-stop": backup,
"service-restart": backup,
"service-reload": backup,
"storage-allocate": backup,
"storage-upload": backup,
"prune-backups": backup,
"acme": backup,
"sdn-apply": backup,
"ceph-flags": backup,
"cluster-create": backup,
"cluster-join": backup,
}, },
concurrency=resolved.task_worker_concurrency, concurrency=resolved.task_worker_concurrency,
lease_seconds=resolved.task_lease_seconds, lease_seconds=resolved.task_lease_seconds,
+25
View File
@@ -0,0 +1,25 @@
"""Canonical Ceph cluster flag names (PVE /cluster/ceph/flags)."""
from __future__ import annotations
# Keep in sync with Proxmox `ceph_flags` API (majors 6-9).
CEPH_FLAG_DESCRIPTIONS: dict[str, str] = {
"nobackfill": "Pause backfill operations",
"nodeep-scrub": "Disable deep scrubbing",
"nodown": "Do not mark OSDs down",
"noin": "Do not mark OSDs in",
"noout": "Do not mark OSDs out",
"norebalance": "Pause rebalance",
"norecover": "Pause recovery",
"noscrub": "Disable scrubbing",
"notieragent": "Pause tiering agent",
"noup": "Do not mark OSDs up",
"pause": "Pause I/O",
}
def default_ceph_flags(*, enabled: frozenset[str] | None = None) -> dict[str, int]:
"""Return the full flag map; unset flags are ``0`` (PVE GET → ``false``)."""
active = enabled or frozenset()
return {name: (1 if name in active else 0) for name in CEPH_FLAG_DESCRIPTIONS}
+1022 -202
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -27,7 +27,7 @@ from app.contracts.model import Method, Snapshot
from app.db.migrations import migrate from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase from app.db.pool import AsyncpgDatabase
from app.main import create_app from app.main import create_app
from app.simulation.seed import apply_seed, small_profile from app.simulation.seed import apply_seed, lab_profile
from app.web.contract_catalog import get_major_releases from app.web.contract_catalog import get_major_releases
_BUNDLED_9 = Path( _BUNDLED_9 = Path(
@@ -119,7 +119,7 @@ async def prepare_db(url: str) -> None:
connection = await asyncpg.connect(url) connection = await asyncpg.connect(url)
try: try:
await migrate(connection) await migrate(connection)
await apply_seed(connection, small_profile()) await apply_seed(connection, lab_profile())
finally: finally:
await connection.close() await connection.close()
+5
View File
@@ -43,6 +43,11 @@ def backup_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
await repository.append_log(task.id, "apt update finished") await repository.append_log(task.id, "apt update finished")
return {"status": "OK"} return {"status": "OK"}
# Node/cluster ops whose durable side effects were applied by the API handler.
if task.task_type != "vzdump":
await repository.append_log(task.id, f"{task.task_type} completed")
return {"status": "OK"}
node = str(task.payload["node"]) node = str(task.payload["node"])
vmids = [str(item) for item in task.payload.get("vmids", [])] vmids = [str(item) for item in task.payload.get("vmids", [])]
storage_id = str(task.payload.get("storage") or "nfs-backup") storage_id = str(task.payload.get("storage") or "nfs-backup")
+3
View File
@@ -29,6 +29,9 @@ def lxc_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
) )
if operation == "migrate" or operation == "remote-migrate": if operation == "migrate" or operation == "remote-migrate":
return await _migrate(repository, task, resource_id, clock) return await _migrate(repository, task, resource_id, clock)
if operation == "resize":
await repository.append_log(task.id, "container resize completed")
return {"status": "OK"}
async with repository.pool.acquire() as connection: async with repository.pool.acquire() as connection:
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id) row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
if row is None: if row is None:
+4
View File
@@ -33,6 +33,10 @@ def qemu_handler(repository: TaskRepository, clock: Clock) -> TaskHandler:
return await _migrate(repository, task, resource_id, clock) return await _migrate(repository, task, resource_id, clock)
if operation == "move-disk": if operation == "move-disk":
return await _move_disk(repository, task, resource_id) return await _move_disk(repository, task, resource_id)
if operation in {"resize", "template"}:
# Side effects already persisted by the API handler; complete the worker.
await repository.append_log(task.id, f"VM {operation} completed")
return {"status": "OK"}
async with repository.pool.acquire() as connection: async with repository.pool.acquire() as connection:
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id) row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
if row is None: if row is None:
+138 -12
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from app.api.openapi import contract_openapi_tag from app.api.openapi import contract_openapi_tag
from app.config import Settings from app.config import Settings
from app.contracts.examples import path_param_example, schema_example from app.contracts.examples import path_param_example, schema_example, wire_param_name
from app.contracts.importer import RemoteSourceImporter from app.contracts.importer import RemoteSourceImporter
from app.contracts.model import Method, Parameter, Snapshot from app.contracts.model import Method, Parameter, Snapshot
from app.contracts.normalize import normalize_snapshot from app.contracts.normalize import normalize_snapshot
@@ -226,16 +226,25 @@ def _path_param_names(path: str) -> tuple[str, ...]:
return tuple(match.group(1) for match in _PATH_PARAM.finditer(path)) return tuple(match.group(1) for match in _PATH_PARAM.finditer(path))
def _parameter_payload(parameter: Parameter) -> dict[str, object]: def _parameter_payload(parameter: Parameter, *, wire_name: str | None = None) -> dict[str, object]:
schema = parameter.definition schema = parameter.definition
return { name = wire_name or parameter.name
"name": parameter.name, typetext = schema.extra.get("typetext")
payload: dict[str, object] = {
"name": name,
"type": schema.type, "type": schema.type,
"description": schema.description, "description": schema.description,
"optional": bool(schema.optional), "optional": bool(schema.optional),
"enum": list(schema.enum), "enum": list(schema.enum),
"example": schema_example(schema, name=parameter.name), "example": schema_example(schema, name=parameter.name),
} }
if wire_name and wire_name != parameter.name:
payload["template"] = parameter.name
if isinstance(schema.format, str):
payload["format"] = schema.format
if isinstance(typetext, str) and typetext:
payload["typetext"] = typetext
return payload
def method_payload( def method_payload(
@@ -274,15 +283,20 @@ def method_payload(
"example": path_param_example(name) or name, "example": path_param_example(name) or name,
} }
) )
body_fields = [ contract_body_fields = [
_parameter_payload(parameter) _parameter_payload(parameter)
for parameter in method.parameters for parameter in method.parameters
if parameter.name not in path_params and "[n]" not in parameter.name if parameter.name not in path_params and "[n]" not in parameter.name
] ]
# Contract uses foo[n]; the wire form is foo0..fooN — seed the console with foo0.
indexed_fields = [ indexed_fields = [
_parameter_payload(parameter) for parameter in method.parameters if "[n]" in parameter.name _parameter_payload(parameter, wire_name=wire_param_name(parameter.name))
for parameter in method.parameters
if "[n]" in parameter.name
] ]
body_example = _body_example(method, path_params) body_example = _body_example(method, path_params)
# Flatten nested scalars from body_example (and nested field examples) into PARAMS.
body_fields = _body_fields_with_nested(contract_body_fields, body_example)
resolved_path = _resolve_path(path, path_fields) resolved_path = _resolve_path(path, path_fields)
implemented = ( implemented = (
(path, method.verb.upper()) in implemented_methods (path, method.verb.upper()) in implemented_methods
@@ -316,21 +330,133 @@ def _resolve_path(path: str, path_fields: list[dict[str, object]]) -> str:
def _body_example(method: Method, path_params: tuple[str, ...]) -> dict[str, object]: def _body_example(method: Method, path_params: tuple[str, ...]) -> dict[str, object]:
"""Minimal request body: required parameters only, Proxmox wire names/values."""
body: dict[str, object] = {} body: dict[str, object] = {}
for parameter in method.parameters: for parameter in method.parameters:
if parameter.name in path_params: if parameter.name in path_params:
continue continue
if "[n]" in parameter.name:
concrete = parameter.name.replace("[n]", "0")
if not parameter.definition.optional:
body[concrete] = schema_example(parameter.definition, name=concrete)
continue
if parameter.definition.optional: if parameter.definition.optional:
continue continue
body[parameter.name] = schema_example(parameter.definition, name=parameter.name) wire = wire_param_name(parameter.name)
body[wire] = schema_example(parameter.definition, name=parameter.name)
return body return body
def _leaf_type(value: object) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int) and not isinstance(value, bool):
return "integer"
if isinstance(value, float):
return "number"
return "string"
def _body_fields_from_example(body_example: dict[str, object] | None) -> list[dict[str, object]]:
"""PARAM inputs derived from body_example, including nested scalar paths."""
if not isinstance(body_example, dict) or not body_example:
return []
inner: object = body_example
# Soft unwrap a single root object envelope (Engine-style), never a scalar/list.
if len(body_example) == 1:
only = next(iter(body_example.values()))
if isinstance(only, dict):
inner = only
if not isinstance(inner, dict):
return []
fields: list[dict[str, object]] = []
def _walk(prefix: str, value: object) -> None:
if isinstance(value, dict):
for key, child in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
_walk(path, child)
return
if isinstance(value, list):
for index, child in enumerate(value):
path = f"{prefix}.{index}" if prefix else str(index)
_walk(path, child)
return
fields.append(
{
"name": prefix,
"type": _leaf_type(value),
"description": prefix,
"optional": True,
"enum": [],
"example": value,
}
)
_walk("", inner)
return fields
def _body_fields_with_nested(
contract_fields: list[dict[str, object]],
body_example: dict[str, object],
) -> list[dict[str, object]]:
"""Merge contract PARAMS with nested scalar paths from body_example."""
example_fields = _body_fields_from_example(body_example)
nested_tops = {
str(item["name"]).split(".", 1)[0] for item in example_fields if "." in str(item["name"])
}
contract_by_name = {str(item["name"]): item for item in contract_fields}
merged: list[dict[str, object]] = []
seen: set[str] = set()
for item in example_fields:
name = str(item["name"])
top = name.split(".", 1)[0]
parent = contract_by_name.get(name) or contract_by_name.get(top)
row = dict(item)
if parent is not None:
row["optional"] = bool(parent.get("optional"))
description = parent.get("description")
if isinstance(description, str) and description:
row["description"] = description
typetext = parent.get("typetext")
if isinstance(typetext, str) and typetext:
row["typetext"] = typetext
fmt = parent.get("format")
if isinstance(fmt, str) and fmt:
row["format"] = fmt
else:
# body_example is required-only — treat uncovered leaves as required.
row["optional"] = False
merged.append(row)
seen.add(name)
for field in contract_fields:
name = str(field["name"])
if name in nested_tops or name in seen:
continue
example = field.get("example")
if isinstance(example, dict | list):
nested = _body_fields_from_example({name: example})
if nested:
for item in nested:
nested_name = str(item["name"])
if nested_name in seen:
continue
row = dict(item)
row["optional"] = bool(field.get("optional"))
description = field.get("description")
if isinstance(description, str) and description:
row["description"] = description
merged.append(row)
seen.add(nested_name)
continue
# Empty object/array examples stay as a top-level PARAM.
merged.append(field)
seen.add(name)
return merged
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def default_store_root() -> Path: def default_store_root() -> Path:
return _DEFAULT_STORE return _DEFAULT_STORE
+331 -54
View File
@@ -583,6 +583,123 @@
padding-top: 2px; padding-top: 2px;
} }
.demo-size-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 4px;
}
@media (max-width: 900px) {
.demo-size-grid {
grid-template-columns: 1fr;
}
}
.demo-size-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 14px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background:
linear-gradient(165deg, color-mix(in srgb, var(--brand-accent) 8%, var(--surface-raised)) 0%, var(--surface-raised) 55%);
min-height: 168px;
}
.demo-size-card.is-active {
border-color: color-mix(in srgb, var(--brand-accent) 55%, var(--border));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--brand-accent) 25%, transparent);
}
.demo-size-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.demo-size-card-title {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.01em;
color: var(--text);
text-transform: capitalize;
}
.demo-size-card-chip {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand-accent);
background: color-mix(in srgb, var(--brand-accent) 12%, transparent);
border-radius: 999px;
padding: 3px 8px;
white-space: nowrap;
}
.demo-size-card-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 10px;
margin: 0;
}
.demo-size-card-metrics div {
display: flex;
flex-direction: column;
gap: 1px;
}
.demo-size-card-metrics dt {
margin: 0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.demo-size-card-metrics dd {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.demo-size-card-note {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--muted);
flex: 1;
}
.demo-size-card .btn-demo-load {
width: 100%;
justify-content: center;
margin-top: auto;
}
.demo-toolbar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
width: 100%;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.demo-toolbar .btn {
width: 100%;
box-sizing: border-box;
justify-content: center;
text-align: center;
}
.btn-demo-load { .btn-demo-load {
background: var(--brand-accent); background: var(--brand-accent);
border-color: var(--brand-accent); border-color: var(--brand-accent);
@@ -5371,11 +5488,45 @@
function buildDemoDataHtml(data) { function buildDemoDataHtml(data) {
const loaded = Boolean(data?.loaded); const loaded = Boolean(data?.loaded);
const profile = data?.profile ?? "minimal";
const badgeLabel = loaded ? String(profile) : "Minimal";
const cephPiB = data?.ceph_capacity_pib; const cephPiB = data?.ceph_capacity_pib;
const cephText = cephPiB != null && cephPiB > 0 ? `${cephPiB} PiB` : "—"; const cephText = cephPiB != null && cephPiB > 0 ? `${cephPiB} PiB` : "—";
const tasksText = data?.tasks != null ? `${data.tasks} / ${data.task_logs ?? 0}` : "—"; const tasksText = data?.tasks != null ? `${data.tasks} / ${data.task_logs ?? 0}` : "—";
const sizes = [
{
id: "small",
label: "Small",
chip: "lab",
hosts: 3,
guests: 50,
osd: 10,
backups: 10,
note: "3 hosts · 50 guests · 10 OSD · HA/replication light",
},
{
id: "large",
label: "Large",
chip: "default",
hosts: 10,
guests: 1000,
osd: 100,
backups: 80,
note: "10 hosts · 1000 guests · 100 OSD · fuller Ceph/HA",
},
{
id: "big",
label: "Big",
chip: "heavy",
hosts: 20,
guests: 2000,
osd: 500,
backups: 160,
note: "20 hosts · 2000 guests · 500 OSD · heavy Ceph/HA",
},
];
const stats = [ const stats = [
["Profile", data?.profile ?? "—", loaded ? "ok" : ""], ["Profile", profile, loaded ? "ok" : ""],
["Cluster", data?.cluster_name ?? "—", ""], ["Cluster", data?.cluster_name ?? "—", ""],
["Nodes", data?.nodes ?? "—", ""], ["Nodes", data?.nodes ?? "—", ""],
["QEMU", data?.qemu ?? "—", ""], ["QEMU", data?.qemu ?? "—", ""],
@@ -5390,13 +5541,12 @@
return ` return `
<div class="help-demo-panel"> <div class="help-demo-panel">
<div class="help-report-head help-demo-head"> <div class="help-report-head help-demo-head">
<h3>Demo cluster</h3> <h3>Proxmox demo cluster</h3>
<p>Load a realistic enterprise environment into the emulator database for hands-on API exploration.</p> <p>Pick a cluster size — inventory (nodes, guests, Ceph, HA, backups, tasks) scales together.</p>
<span class="help-demo-badge ${loaded ? "loaded" : "empty"}">${loaded ? "Loaded" : "Empty"}</span> <span class="help-demo-badge ${loaded ? "loaded" : "empty"}">${escapeHtml(badgeLabel)}</span>
</div> </div>
<p class="help-demo-note"> <p class="help-demo-note">
20 nodes · 1000 guests (850 QEMU + 150 LXC) · Ceph 300 OSD (~5 PiB) · backups, logs, Password for seeded users: <code>secret</code>. Loading replaces current Proxmox state.
snapshots, pools, HA rules, and multi-level users. Loading replaces current simulation state.
</p> </p>
<div class="help-stat-grid"> <div class="help-stat-grid">
${stats.map(([label, value, tone]) => ` ${stats.map(([label, value, tone]) => `
@@ -5406,9 +5556,30 @@
</div> </div>
`).join("")} `).join("")}
</div> </div>
<div class="help-demo-actions"> <div class="demo-size-grid" role="group" aria-label="Cluster sizes">
<button class="btn btn-sm btn-demo-load" id="btn-demo-load" type="button">Load demo cluster</button> ${sizes.map((s) => {
<button class="btn btn-sm btn-demo-unload" id="btn-demo-unload" type="button">Remove demo data</button> const active = profile === s.id;
return `
<article class="demo-size-card ${active ? "is-active" : ""}" data-size-card="${s.id}">
<div class="demo-size-card-head">
<h4 class="demo-size-card-title">${escapeHtml(s.label)}</h4>
<span class="demo-size-card-chip">${escapeHtml(s.chip)}</span>
</div>
<dl class="demo-size-card-metrics">
<div><dt>Hosts</dt><dd>${s.hosts}</dd></div>
<div><dt>Guests</dt><dd>${s.guests}</dd></div>
<div><dt>OSD</dt><dd>${s.osd}</dd></div>
<div><dt>Backups</dt><dd>${s.backups}</dd></div>
</dl>
<p class="demo-size-card-note">${escapeHtml(s.note)}</p>
<button class="btn btn-sm btn-demo-load" data-demo-size="${s.id}" type="button">
Load ${escapeHtml(s.label.toLowerCase())}
</button>
</article>`;
}).join("")}
</div>
<div class="demo-toolbar">
<button class="btn btn-sm btn-demo-unload" id="btn-demo-unload" type="button">Reset to minimal</button>
<button class="btn btn-sm" id="btn-demo-refresh" type="button">Refresh stats</button> <button class="btn btn-sm" id="btn-demo-refresh" type="button">Refresh stats</button>
</div> </div>
</div> </div>
@@ -5446,35 +5617,45 @@
renderDemoState(await res.json()); renderDemoState(await res.json());
} }
async function loadDemoData() { async function loadDemoData(size = "small") {
const loadBtn = document.getElementById("btn-demo-load"); const loadBtns = document.querySelectorAll("[data-demo-size]");
if (loadBtn) loadBtn.disabled = true; loadBtns.forEach((btn) => { btn.disabled = true; });
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/ui/api/demo/load", { method: "POST" }); const res = await fetch(`/ui/api/demo/load?profile=${encodeURIComponent(size)}`, {
method: "POST",
});
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
throw new Error(body.detail || `Load failed: ${res.status}`); const detail = Array.isArray(body.detail)
? body.detail.map((d) => d.msg || d).join("; ")
: body.detail;
throw new Error(detail || `Load failed: ${res.status}`);
} }
renderDemoState(body.summary || {}); renderDemoState(body.summary || {});
if (state.ticket) syncAuthCookie(state.ticket); if (state.ticket) syncAuthCookie(state.ticket);
refreshOverview(); refreshOverview();
toast("Demo cluster loaded", "ok"); const seed = body.summary || {};
const guests = (seed.qemu ?? 0) + (seed.lxc ?? 0);
toast(
`Cluster ${seed.profile || size} loaded (${seed.nodes ?? "?"} hosts · ${guests || "?"} guests)`,
"ok",
);
if (!state.ticket) { if (!state.ticket) {
toast("Sign in (root@pam / secret) to call API endpoints", "warn"); toast("Sign in (root@pam / secret) to call API endpoints", "warn");
} }
} finally { } finally {
if (loadBtn) loadBtn.disabled = false; loadBtns.forEach((btn) => { btn.disabled = false; });
setLoading(false); setLoading(false);
} }
} }
async function unloadDemoData() { async function unloadDemoData() {
const confirmed = await showConfirm({ const confirmed = await showConfirm({
title: "Remove demo data?", title: "Reset to minimal?",
message: "This wipes simulator DB state (demo data and anything created via the API), then reloads a minimal cluster.", message: "This wipes current simulator DB state (sized demo and anything created via the API), then loads the minimal cluster (1 host · 0 guests).",
confirmLabel: "Remove demo data", confirmLabel: "Reset to minimal",
cancelLabel: "Keep demo data", cancelLabel: "Keep current data",
tone: "danger", tone: "danger",
}); });
if (!confirmed) return; if (!confirmed) return;
@@ -5489,7 +5670,7 @@
} }
renderDemoState(body.summary || {}); renderDemoState(body.summary || {});
refreshOverview(); refreshOverview();
toast("Demo data removed", "info"); toast("Reset to minimal cluster", "info");
} finally { } finally {
if (unloadBtn) unloadBtn.disabled = false; if (unloadBtn) unloadBtn.disabled = false;
setLoading(false); setLoading(false);
@@ -5730,17 +5911,21 @@
if (!els.dataPanel || els.dataPanel.dataset.demoBound) return; if (!els.dataPanel || els.dataPanel.dataset.demoBound) return;
els.dataPanel.dataset.demoBound = "1"; els.dataPanel.dataset.demoBound = "1";
els.dataPanel.addEventListener("click", (event) => { els.dataPanel.addEventListener("click", (event) => {
const target = event.target.closest("#btn-demo-load, #btn-demo-unload, #btn-demo-refresh"); const loadBtn = event.target.closest("[data-demo-size]");
if (!target) return; if (loadBtn) {
if (target.id === "btn-demo-load") { const size = loadBtn.getAttribute("data-demo-size") || "small";
loadDemoData().catch((error) => { loadDemoData(size).catch((error) => {
showError(String(error)); showError(String(error));
toast("Failed to load demo data", "error"); toast("Failed to load cluster data", "error");
}); });
} else if (target.id === "btn-demo-unload") { return;
}
const target = event.target.closest("#btn-demo-unload, #btn-demo-refresh");
if (!target) return;
if (target.id === "btn-demo-unload") {
unloadDemoData().catch((error) => { unloadDemoData().catch((error) => {
showError(String(error)); showError(String(error));
toast("Failed to remove demo data", "error"); toast("Failed to reset to minimal", "error");
}); });
} else if (target.id === "btn-demo-refresh") { } else if (target.id === "btn-demo-refresh") {
renderDataPanel().catch((error) => showError(String(error))); renderDataPanel().catch((error) => showError(String(error)));
@@ -6281,12 +6466,24 @@
} }
container.innerHTML = fields.map((f) => { container.innerHTML = fields.map((f) => {
const stored = kind === "path" ? state.pathValues[f.name] : state.bodyValues[f.name]; const stored = kind === "path" ? state.pathValues[f.name] : state.bodyValues[f.name];
const val = stored ?? f.example ?? ""; const fallback = kind === "path" || !f.optional ? formatParamExample(f.example) : "";
const desc = f.description ? `<div style="font-size:11px;color:var(--muted);margin-top:2px">${f.description}</div>` : ""; const val = stored ?? fallback;
const hint = f.typetext || f.format || "";
const descParts = [];
if (f.description) descParts.push(f.description);
if (hint) descParts.push(`<code style="font-size:10px">${escapeHtml(String(hint))}</code>`);
if (f.template) descParts.push(`<span style="font-size:10px;color:var(--muted)">${escapeHtml(f.template)} → ${escapeHtml(f.name)}</span>`);
const desc = descParts.length
? `<div style="font-size:11px;color:var(--muted);margin-top:2px">${descParts.join(" · ")}</div>`
: "";
const placeholderExample = formatParamExample(f.example);
const placeholder = f.optional && placeholderExample
? ` placeholder="${placeholderExample.replaceAll('"', "&quot;")}"`
: "";
return `<tr> return `<tr>
<td><code>${f.name}</code>${desc}</td> <td><code>${escapeHtml(f.name)}</code>${desc}</td>
<td>${formatParamTypeBadges(f)}</td> <td>${formatParamTypeBadges(f)}</td>
<td><input data-field="${f.name}" data-kind="${kind}" value="${String(val).replaceAll('"', "&quot;")}"></td> <td><input data-field="${escapeAttr(f.name)}" data-kind="${kind}" value="${String(val).replaceAll('"', "&quot;")}"${placeholder}></td>
</tr>`; </tr>`;
}).join(""); }).join("");
container.querySelectorAll("input[data-field]").forEach((input) => { container.querySelectorAll("input[data-field]").forEach((input) => {
@@ -6393,9 +6590,11 @@
try { try {
const parsed = JSON.parse(body || "{}"); const parsed = JSON.parse(body || "{}");
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const { inner } = unwrapBodyExample(parsed);
for (const field of bodyFields) { for (const field of bodyFields) {
if (Object.prototype.hasOwnProperty.call(parsed, field.name)) { const value = getByPath(inner, wireBodyFieldName(field.name));
state.bodyValues[field.name] = String(parsed[field.name] ?? ""); if (value !== undefined) {
state.bodyValues[field.name] = formatParamExample(value);
} }
} }
} }
@@ -6468,31 +6667,106 @@
return target === "cluster" ? buildClusterUrl(apiPath) : buildEmulatorUrl(apiPath); return target === "cluster" ? buildClusterUrl(apiPath) : buildEmulatorUrl(apiPath);
} }
function wireBodyFieldName(name) {
// Contract templates use foo[n]; wire / body keys are foo0 (catalog already expands).
return String(name || "").includes("[n]") ? String(name).replaceAll("[n]", "0") : String(name || "");
}
function deepCloneJson(value) {
if (value == null) return value;
return JSON.parse(JSON.stringify(value));
}
function getByPath(root, path) {
if (!path) return root;
const parts = String(path).split(".");
let cur = root;
for (const part of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = cur[part];
}
return cur;
}
function setByPath(root, path, value) {
const parts = String(path).split(".");
let cur = root;
for (let i = 0; i < parts.length - 1; i += 1) {
const part = parts[i];
const next = parts[i + 1];
const wantArray = /^\d+$/.test(next);
if (cur[part] == null || typeof cur[part] !== "object") {
cur[part] = wantArray ? [] : {};
}
cur = cur[part];
}
cur[parts[parts.length - 1]] = value;
}
function deleteByPath(root, path) {
const parts = String(path).split(".");
let cur = root;
for (let i = 0; i < parts.length - 1; i += 1) {
if (cur == null || typeof cur !== "object") return;
cur = cur[parts[i]];
}
if (cur && typeof cur === "object") delete cur[parts[parts.length - 1]];
}
function unwrapBodyExample(example) {
if (!example || typeof example !== "object" || Array.isArray(example)) {
return { wrapKey: null, inner: {} };
}
const keys = Object.keys(example);
if (
keys.length === 1
&& example[keys[0]]
&& typeof example[keys[0]] === "object"
&& !Array.isArray(example[keys[0]])
) {
return { wrapKey: keys[0], inner: example[keys[0]] };
}
return { wrapKey: null, inner: example };
}
function formatParamExample(value) {
if (value == null) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function syncBodyFromFields({ syncVisibility = true } = {}) { function syncBodyFromFields({ syncVisibility = true } = {}) {
if (!state.method) return; if (!state.method) return;
const inputs = [...(els.bodyFields?.querySelectorAll("input[data-field]") || [])]; const inputs = [...(els.bodyFields?.querySelectorAll("input[data-field]") || [])];
const body = {};
inputs.forEach((input) => {
const name = input.dataset.field;
const raw = input.value.trim();
if (!raw) return;
if (raw === "true" || raw === "false") body[name] = raw === "true";
else if (/^-?\d+$/.test(raw)) body[name] = Number(raw);
else if (/^-?\d+\.\d+$/.test(raw)) body[name] = Number(raw);
else body[name] = raw;
});
// If PARAM fields are empty, fall back to method body_example so the editor
// updates when switching verbs / endpoints.
if (!Object.keys(body).length) {
const example = state.method.body_example; const example = state.method.body_example;
if (example && typeof example === "object" && !Array.isArray(example)) { const { wrapKey, inner } = unwrapBodyExample(example);
for (const [key, value] of Object.entries(example)) { const root = deepCloneJson(inner) || {};
if (value === undefined || value === null || value === "") continue; let touched = false;
body[key] = value; inputs.forEach((input) => {
const name = wireBodyFieldName(input.dataset.field);
if (!name) return;
const raw = input.value.trim();
const existing = getByPath(root, name);
if (!raw && existing === undefined) return;
touched = true;
if (!raw) {
deleteByPath(root, name);
return;
} }
let value = raw;
if (raw === "true" || raw === "false") value = raw === "true";
else if (/^-?\d+$/.test(raw)) value = Number(raw);
else if (/^-?\d+\.\d+$/.test(raw)) value = Number(raw);
setByPath(root, name, value);
});
// Empty PARAM inputs → keep full body_example; edits merge into nested paths.
if (!touched && example && typeof example === "object" && !Array.isArray(example)) {
els.body.value = Object.keys(example).length ? JSON.stringify(example, null, 2) : "";
updateBodyHighlight();
if (syncVisibility) updateBodyPaneVisibility(state.method);
return;
} }
} const body = wrapKey ? { [wrapKey]: root } : root;
// Keep empty object out of the editor — do not show `{}`.
els.body.value = Object.keys(body).length ? JSON.stringify(body, null, 2) : ""; els.body.value = Object.keys(body).length ? JSON.stringify(body, null, 2) : "";
updateBodyHighlight(); updateBodyHighlight();
if (syncVisibility) { if (syncVisibility) {
@@ -6547,7 +6821,10 @@
const bodyFields = [...(d.body_fields || []), ...(d.indexed_fields || [])]; const bodyFields = [...(d.body_fields || []), ...(d.indexed_fields || [])];
state.bodyValues = {}; state.bodyValues = {};
for (const f of bodyFields) { for (const f of bodyFields) {
state.bodyValues[f.name] = resolveParamValue(f.name, storedParams?.bodyValues, f.example); // Seed required fields from the contract/example; leave optional empty
// (placeholder shows the typetext-shaped example until the user fills it).
const fallback = f.optional ? "" : formatParamExample(f.example);
state.bodyValues[f.name] = resolveParamValue(f.name, storedParams?.bodyValues, fallback);
} }
els.bodyFieldsWrap.hidden = !bodyFields.length; els.bodyFieldsWrap.hidden = !bodyFields.length;
renderParamRows(els.bodyFields, bodyFields, "body"); renderParamRows(els.bodyFields, bodyFields, "body");
+18 -2
View File
@@ -161,7 +161,23 @@ async def ui_demo_state(request: Request) -> JSONResponse:
@router.post("/ui/api/demo/load", include_in_schema=False) @router.post("/ui/api/demo/load", include_in_schema=False)
async def ui_demo_load(request: Request) -> JSONResponse: async def ui_demo_load(request: Request) -> JSONResponse:
pool = _database_pool(request) pool = _database_pool(request)
profile = build_profile("demo-cluster") requested = "small"
if request.query_params.get("profile"):
requested = str(request.query_params.get("profile"))
else:
try:
body = await request.json()
except Exception:
body = None
if isinstance(body, dict) and body.get("profile"):
requested = str(body["profile"])
allowed = {"small", "large", "big"}
if requested not in allowed:
raise HTTPException(
status_code=400,
detail=f"unknown cluster profile '{requested}' (expected: small, large, big)",
)
profile = build_profile(requested)
async with pool.acquire() as connection: async with pool.acquire() as connection:
await apply_seed(connection, profile) await apply_seed(connection, profile)
summary = await simulation_state_summary(connection) summary = await simulation_state_summary(connection)
@@ -179,7 +195,7 @@ async def ui_demo_unload(request: Request) -> JSONResponse:
summary = await simulation_state_summary(connection) summary = await simulation_state_summary(connection)
except Exception as error: except Exception as error:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"failed to remove demo data: {error}" status_code=500, detail=f"failed to reset to minimal cluster: {error}"
) from error ) from error
return JSONResponse({"ok": True, "profile": profile.name, "summary": summary}) return JSONResponse({"ok": True, "profile": profile.name, "summary": summary})
+2 -2
View File
@@ -150,8 +150,8 @@ curl -sS -H "Cookie: PVEAuthCookie=$TICKET" \
Visit [http://localhost:8006/](http://localhost:8006/) for the interactive Visit [http://localhost:8006/](http://localhost:8006/) for the interactive
console, contract catalog (PVE 69), compatibility view, runtime contract apply, console, contract catalog (PVE 69), compatibility view, runtime contract apply,
and demo-cluster controls. See [Web UI](web-ui.md) for light/dark theme and demo-cluster controls. See [Web UI](web-ui.md) for screenshots and the
screenshots and the full feature list. full feature list.
## 8. Try a client library ## 8. Try a client library
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+2 -2
View File
@@ -151,8 +151,8 @@ curl -s -H "Cookie: PVEAuthCookie=$TICKET" \
Перейдите на [http://localhost:8006/](http://localhost:8006/) — интерактивная Перейдите на [http://localhost:8006/](http://localhost:8006/) — интерактивная
консоль, каталог контрактов (PVE 6–9), представление совместимости, применение runtime- консоль, каталог контрактов (PVE 6–9), представление совместимости, применение runtime-
контракта и управление demo-кластером. Скриншоты светлой/тёмной темы и полный список контракта и управление demo-кластером. Скриншоты и полный список возможностей —
возможностей — в [Web UI](web-ui.md). в [Web UI](web-ui.md).
## 8. Попробуйте клиентскую библиотеку ## 8. Попробуйте клиентскую библиотеку
+45 -3
View File
@@ -10,14 +10,56 @@ UI — лабораторная консоль симулятора, а не п
## Скриншоты ## Скриншоты
Светлая тема — `GET /cluster/resources` на PVE 9.2.3: Главный экран консоли (тёмная тема, PVE 8.4.5):
![Web UI main](../images/web-ui-main.png)
Светлая / тёмная темы:
![Web UI light theme](../images/web-ui-light.png) ![Web UI light theme](../images/web-ui-light.png)
Тёмная тема — та же консоль с переключателем темы:
![Web UI dark theme](../images/web-ui-dark.png) ![Web UI dark theme](../images/web-ui-dark.png)
Панель Endpoints (методы каталога выбранного мажора):
![Web UI endpoints](../images/web-ui-endpoints.png)
Каталог API — мажоры 6–9, покрытие и **Apply as runtime**:
![Web UI API catalog](../images/web-ui-api-catalog.png)
Редактор запроса (`POST /cluster/config`):
![Web UI request](../images/web-ui-request.png)
Параметры запроса из контракта:
![Web UI request parameters](../images/web-ui-request-params.png)
Аутентификация (`root@pam` / API-токен):
![Web UI authentication](../images/web-ui-authentication.png)
Сводка окружения (runtime, кластер, гости):
![Web UI environment](../images/web-ui-environment.png)
История запросов:
![Web UI history](../images/web-ui-history.png)
Справка / ledger совместимости:
![Web UI help](../images/web-ui-help.png)
Загрузка demo-кластера:
![Web UI data](../images/web-ui-data.png)
Монитор задач UPID:
![Web UI UPID](../images/web-ui-upid.png)
## Возможности ## Возможности
- Дерево эндпоинтов и выбор метода по выбранному мажору каталога - Дерево эндпоинтов и выбор метода по выбранному мажору каталога
+45 -3
View File
@@ -10,14 +10,56 @@ request/response editing, history, and runtime contract apply.
## Screenshots ## Screenshots
Light theme — `GET /cluster/resources` on PVE 9.2.3: Console home (dark theme, PVE 8.4.5):
![Web UI main](images/web-ui-main.png)
Light / dark themes:
![Web UI light theme](images/web-ui-light.png) ![Web UI light theme](images/web-ui-light.png)
Dark theme — same console with theme toggle:
![Web UI dark theme](images/web-ui-dark.png) ![Web UI dark theme](images/web-ui-dark.png)
Endpoints drawer (catalog methods for the selected major):
![Web UI endpoints](images/web-ui-endpoints.png)
API catalog — majors 69, coverage, and **Apply as runtime**:
![Web UI API catalog](images/web-ui-api-catalog.png)
Request editor (`POST /cluster/config`):
![Web UI request](images/web-ui-request.png)
Contract-derived request parameters:
![Web UI request parameters](images/web-ui-request-params.png)
Authentication (`root@pam` / API token):
![Web UI authentication](images/web-ui-authentication.png)
Environment summary (runtime, cluster, guests):
![Web UI environment](images/web-ui-environment.png)
Request history:
![Web UI history](images/web-ui-history.png)
Help / compatibility ledger:
![Web UI help](images/web-ui-help.png)
Demo-cluster load controls:
![Web UI data](images/web-ui-data.png)
UPID task monitor:
![Web UI UPID](images/web-ui-upid.png)
## Features ## Features
- Endpoint tree and method selector driven by the selected catalog major - Endpoint tree and method selector driven by the selected catalog major
+24 -3
View File
@@ -6,10 +6,12 @@ Docker-only suite against the Proxmox API simulator.
| Layer | What it covers | | Layer | What it covers |
|---|---| |---|---|
| **Surface** | **100%** of declared contract methods for PVE majors **69** (every path+verb) via HTTP (`pvelib/surface.py`). Suite PASS requires `declared == probed` per major and zero critical failures (501, “not supported” messages, 5xx, exceptions, unknown verbs). | | **A — Surface (HTTP contract matrix)** | **100%** means every declared contract method for PVE majors **69** (path+verb: GET/PUT/POST/DELETE), plus **synthetic HEAD on each GET path**. Ticket + CSRF on mutations; form-urlencoded bodies; Proxmox `{ "data": … }` envelope. PASS requires `declared == probed` per major **and** aggregate, `critical=0` (501, “not supported”, 5xx, exceptions, empty/wrong 2xx envelopes). HEAD is in the verb histogram but **not** in the declared/probed denominator. |
| **Lifecycle** | **`pulumi-proxmoxve` only** (BPG Terraform bridge) — Provider, inventory data sources, `VmLegacy` + non-empty checks. Negative auth via httpx. This is **not** full API coverage; the provider exposes dozens of resources, not thousands of contract methods. Provider uses internal HTTPS gateway; surface stays HTTP. | | **B — Lifecycle** | **`pulumi-proxmoxve` smoke only** (BPG Terraform bridge) — Provider, inventory data sources, `VmLegacy` + non-empty checks. **Not** 100% API and **not** provider resource-count coverage. HTTPS via lab TLS gateway; surface stays HTTP. |
Reports (`pulumi/reports/report.html`, `results.json`, `junit.xml`) include a **Full contract coverage** summary with per-major and total `declared`/`probed` counts (e.g. `Coverage: 2324/2324 methods across majors 69`). Reports (`pulumi/reports/report.html`, `results.json`, `junit.xml`) include **Full contract coverage**, a **verb histogram (incl. HEAD)**, and a line like:
`Coverage: N/N methods across majors 69 (critical=0)`
## Layout ## Layout
@@ -43,7 +45,26 @@ open pulumi/reports/report.html
make down make down
``` ```
### Quick curl (ticket → GET/POST + report)
Against a running suite stack (`http://localhost:8006`):
```bash
TICKET=$(curl -s -c /tmp/pve.ck -b /tmp/pve.ck \
-d 'username=root@pam&password=secret' \
http://localhost:8006/api2/json/access/ticket)
echo "$TICKET" | jq .
CSRF=$(echo "$TICKET" | jq -r .data.CSRFPreventionToken)
curl -s -b /tmp/pve.ck http://localhost:8006/api2/json/version | jq .
curl -s -b /tmp/pve.ck -H "CSRFPreventionToken: $CSRF" \
-d 'vmid=100&name=demo' \
http://localhost:8006/api2/json/nodes/pve01/qemu | jq .
```
Open the HTML report: `pulumi-tests/pulumi/reports/report.html`.
Provider env (set by Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:8443/` Provider env (set by Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:8443/`
(internal suite TLS — `pulumi-proxmoxve` rejects `http://`), plus username/password (internal suite TLS — `pulumi-proxmoxve` rejects `http://`), plus username/password
/`INSECURE`. Surface probe uses `API_URL=http://simulator:8006`. Host lab URL /`INSECURE`. Surface probe uses `API_URL=http://simulator:8006`. Host lab URL
remains plain `http://localhost:8006/` (Kubernetes HTTPS is Ingress-only). remains plain `http://localhost:8006/` (Kubernetes HTTPS is Ingress-only).
Seed for CI/suite: `SEED_PROFILE=small` (Compose default).
+24 -3
View File
@@ -6,10 +6,12 @@ Suite только в Docker против симулятора Proxmox API.
| Слой | Покрытие | | Слой | Покрытие |
|---|---| |---|---|
| **Surface** | **100%** объявленных методов контракта majors **69** (каждый path+verb) по HTTP (`pvelib/surface.py`). PASS только при `declared == probed` на каждый major и нуле критичных сбоев (501, «not supported», 5xx, exceptions, неизвестные verb). | | **A — Surface (HTTP contract matrix)** | **100%** = каждый объявленный метод контракта majors **69** (path+verb: GET/PUT/POST/DELETE) плюс **синтетический HEAD на каждый GET path**. Ticket + CSRF на мутациях; form-urlencoded; оболочка `{ "data": … }`. PASS при `declared == probed` на major и в сумме, `critical=0` (501, «not supported», 5xx, exceptions, пустые/неверные 2xx envelope). HEAD — в гистограмме глаголов, **не** в знаменателе declared/probed. |
| **Lifecycle** | Только **`pulumi-proxmoxve`** (мост BPG) — Provider, data sources инвентаря, `VmLegacy` + проверки непустых outputs. Это **не** полное покрытие API: в провайдере десятки ресурсов, не тысячи contract methods. HTTPS через lab TLS-шлюз; surface — HTTP. | | **B — Lifecycle** | Только smoke **`pulumi-proxmoxve`** (мост BPG) — Provider, data sources инвентаря, `VmLegacy` + непустые outputs. Это **не** 100% API и **не** покрытие по числу ресурсов провайдера. HTTPS через lab TLS-шлюз; surface — HTTP. |
В отчётах (`pulumi/reports/report.html`, `results.json`, `junit.xml`) — блок **Full contract coverage** с итогами `declared`/`probed` по major и суммой (например `Coverage: 2324/2324 methods across majors 69`). В отчётах (`pulumi/reports/report.html`, `results.json`, `junit.xml`) — **Full contract coverage**, **verb histogram (вкл. HEAD)** и строка вида:
`Coverage: N/N methods across majors 69 (critical=0)`
## Структура ## Структура
@@ -43,7 +45,26 @@ open pulumi/reports/report.html
make down make down
``` ```
### Короткий curl (ticket → GET/POST + отчёт)
При поднятом стеке (`http://localhost:8006`):
```bash
TICKET=$(curl -s -c /tmp/pve.ck -b /tmp/pve.ck \
-d 'username=root@pam&password=secret' \
http://localhost:8006/api2/json/access/ticket)
echo "$TICKET" | jq .
CSRF=$(echo "$TICKET" | jq -r .data.CSRFPreventionToken)
curl -s -b /tmp/pve.ck http://localhost:8006/api2/json/version | jq .
curl -s -b /tmp/pve.ck -H "CSRFPreventionToken: $CSRF" \
-d 'vmid=100&name=demo' \
http://localhost:8006/api2/json/nodes/pve01/qemu | jq .
```
Отчёт: `pulumi-tests/pulumi/reports/report.html`.
Env провайдера (Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:8443/` Env провайдера (Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:8443/`
(внутренний TLS suite — `pulumi-proxmoxve` не принимает `http://`), плюс (внутренний TLS suite — `pulumi-proxmoxve` не принимает `http://`), плюс
username/password/`INSECURE`. Surface probe: `API_URL=http://simulator:8006`. username/password/`INSECURE`. Surface probe: `API_URL=http://simulator:8006`.
Хостовый lab URL — plain `http://localhost:8006/` (HTTPS в K8s — только Ingress). Хостовый lab URL — plain `http://localhost:8006/` (HTTPS в K8s — только Ingress).
Seed для CI/suite: `SEED_PROFILE=small` (default Compose).
@@ -0,0 +1 @@
encryptionsalt: v1:Im3SuXTFiBk=:v1:oUxFIClVSpmc6Jkb:AXfMbHESIZWB3tnQLGNdQNp2ICM+UA==
@@ -0,0 +1,3 @@
encryptionsalt: v1:7PSO8VI3JSQ=:v1:LKcWFamTow4SZLl4:ItaMZLsAtkKhooVaycXnDR7F2dWwmA==
config:
hx-lifecycle:smoke: "0"
+156 -4
View File
@@ -2,6 +2,12 @@
Mirrors ``app/surface_probe.py`` classification and path/body synthesis, but Mirrors ``app/surface_probe.py`` classification and path/body synthesis, but
talks HTTP to ``API_URL`` instead of an in-process ASGI app. talks HTTP to ``API_URL`` instead of an in-process ASGI app.
Layer A matrix:
- every declared path+verb (GET/PUT/POST/DELETE)
- synthetic HEAD on each GET path (histogram only; not counted in declared)
- ticket + CSRF on mutations; form-urlencoded bodies
- Proxmox ``{ "data": }`` envelope checks on 2xx
""" """
from __future__ import annotations from __future__ import annotations
@@ -31,6 +37,11 @@ _FORBIDDEN = re.compile(
r"handler pending for this contract method|is not supported in the (emulator|simulator)", r"handler pending for this contract method|is not supported in the (emulator|simulator)",
re.I, re.I,
) )
_UPID_RE = re.compile(
r"^UPID:[A-Za-z0-9][A-Za-z0-9_-]*:"
r"[0-9A-Fa-f]{8}:[0-9A-Fa-f]{8}:[0-9A-Fa-f]{8}:"
r"[A-Za-z0-9_-]+:[^:]*:[^:]+:$"
)
_PATH_PARAM_EXAMPLES: dict[str, object] = { _PATH_PARAM_EXAMPLES: dict[str, object] = {
"node": "pve01", "node": "pve01",
@@ -50,6 +61,11 @@ _PATH_PARAM_EXAMPLES: dict[str, object] = {
"key": "cpu", "key": "cpu",
"digest": "00000000", "digest": "00000000",
"name": "example", "name": "example",
"size": "1G",
"filename": "vm-100-disk-0.raw",
"certificates": "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
"contact": "mailto:admin@example.com",
"clustername": "lab",
} }
_EXTRA_PATH: dict[str, object] = { _EXTRA_PATH: dict[str, object] = {
@@ -85,7 +101,36 @@ _EXTRA_PATH: dict[str, object] = {
} }
CRITICAL_BUCKETS = frozenset( CRITICAL_BUCKETS = frozenset(
{"unimplemented_501", "unsupported_message", "server_5xx", "exception"} {
"unimplemented_501",
"unsupported_message",
"server_5xx",
"exception",
"empty_success_body",
"wrong_returns_envelope",
}
)
# Path suffixes that real PVE usually runs via fork_worker → UPID string.
_WORKER_SUFFIXES = (
"/status/start",
"/status/stop",
"/status/shutdown",
"/status/reboot",
"/status/reset",
"/status/suspend",
"/status/resume",
"/clone",
"/migrate",
"/remote_migrate",
"/snapshot",
"/rollback",
"/template",
"/resize",
"/move_disk",
"/move_volume",
"/vzdump",
"/apt/update",
) )
@@ -195,6 +240,64 @@ def classify(status: int, text: str) -> str:
return f"other_{status}" return f"other_{status}"
def returns_type(method: dict[str, Any]) -> str:
returns = method.get("returns") or {}
if isinstance(returns, dict):
return str(returns.get("type") or "")
return ""
def looks_like_worker(path_template: str, verb: str) -> bool:
if verb.upper() not in {"POST", "PUT"}:
return False
lowered = path_template.lower()
return any(lowered.endswith(suffix) for suffix in _WORKER_SUFFIXES)
def classify_envelope(
*,
status: int,
text: str,
method: dict[str, Any],
path_template: str,
verb: str,
) -> str | None:
"""Return an extra critical bucket for 2xx envelope/returns mismatches, else None."""
if not (200 <= status < 300) or verb.upper() == "HEAD":
return None
returns = method.get("returns") or {}
rtype = returns_type(method)
try:
payload = json.loads(text) if text else None
except json.JSONDecodeError:
return "empty_success_body"
if not isinstance(payload, dict) or "data" not in payload:
return "empty_success_body"
data = payload.get("data")
if rtype == "null":
return None
if rtype == "string":
if data is None:
return "wrong_returns_envelope"
if looks_like_worker(path_template, verb):
if not isinstance(data, str) or not _UPID_RE.fullmatch(data):
return "wrong_returns_envelope"
return None
if isinstance(data, str):
return None
# Older PVE schemas sometimes mark list endpoints as opaque ``string``
# (empty properties, no description). Accept non-null structured data.
if isinstance(returns, dict) and not (
returns.get("description") or returns.get("properties") or returns.get("enum")
):
return None
return "wrong_returns_envelope"
if rtype in {"array", "object"} and data is None:
return "wrong_returns_envelope"
return None
def probe_major( def probe_major(
client: httpx.Client, client: httpx.Client,
csrf: str, csrf: str,
@@ -209,6 +312,8 @@ def probe_major(
by_verb: dict[str, Counter[str]] = defaultdict(Counter) by_verb: dict[str, Counter[str]] = defaultdict(Counter)
failures: list[dict[str, Any]] = [] failures: list[dict[str, Any]] = []
method_results: list[dict[str, Any]] = [] method_results: list[dict[str, Any]] = []
head_results: list[dict[str, Any]] = []
get_paths: set[str] = set()
methods: list[tuple[str, dict[str, Any]]] = [ methods: list[tuple[str, dict[str, Any]]] = [
(path["path"], method) (path["path"], method)
@@ -225,6 +330,7 @@ def probe_major(
params = body_for(method, path_template) params = body_for(method, path_template)
try: try:
if verb == "GET": if verb == "GET":
get_paths.add(path_template)
response = client.get(url, headers=headers, params=params or None) response = client.get(url, headers=headers, params=params or None)
elif verb == "PUT": elif verb == "PUT":
response = client.put(url, data=params or {}, headers=headers) response = client.put(url, data=params or {}, headers=headers)
@@ -232,9 +338,7 @@ def probe_major(
response = client.post(url, data=params or {}, headers=headers) response = client.post(url, data=params or {}, headers=headers)
elif verb == "DELETE": elif verb == "DELETE":
# Proxmox accepts delete identifiers as form or query params. # Proxmox accepts delete identifiers as form or query params.
response = client.request( response = client.request("DELETE", url, data=params or {}, headers=headers)
"DELETE", url, data=params or {}, headers=headers
)
else: else:
by_verb[verb or "UNKNOWN"]["exception"] += 1 by_verb[verb or "UNKNOWN"]["exception"] += 1
item = { item = {
@@ -262,6 +366,15 @@ def probe_major(
text = response.text text = response.text
bucket = classify(response.status_code, text) bucket = classify(response.status_code, text)
envelope = classify_envelope(
status=response.status_code,
text=text,
method=method,
path_template=path_template,
verb=verb,
)
if envelope is not None:
bucket = envelope
by_verb[verb][bucket] += 1 by_verb[verb][bucket] += 1
ok = bucket not in CRITICAL_BUCKETS ok = bucket not in CRITICAL_BUCKETS
item = { item = {
@@ -270,12 +383,40 @@ def probe_major(
"status": response.status_code, "status": response.status_code,
"bucket": bucket, "bucket": bucket,
"ok": ok, "ok": ok,
"returns": returns_type(method),
} }
if not ok: if not ok:
item["body"] = text[:240] item["body"] = text[:240]
failures.append(item) failures.append(item)
method_results.append(item) method_results.append(item)
# Synthetic HEAD for every GET path (Starlette mirrors GET handlers).
for path_template in sorted(get_paths):
url = f"/api2/json{render_path(path_template)}"
try:
response = client.request("HEAD", url)
text = response.text
bucket = classify(response.status_code, text)
except Exception as exc: # noqa: BLE001
bucket = "exception"
text = str(exc)[:200]
response = None
by_verb["HEAD"][bucket] += 1
ok = bucket not in CRITICAL_BUCKETS
item = {
"verb": "HEAD",
"path": path_template,
"status": getattr(response, "status_code", None),
"bucket": bucket,
"ok": ok,
"synthetic": True,
}
if not ok:
item["body"] = text[:240]
item["error"] = text[:200]
failures.append(item)
head_results.append(item)
version, _ = MAJOR_REVISIONS[major] version, _ = MAJOR_REVISIONS[major]
declared = int(snapshot.get("method_count") or len(method_results)) declared = int(snapshot.get("method_count") or len(method_results))
critical = len(failures) critical = len(failures)
@@ -309,13 +450,23 @@ def probe_major(
) )
critical = len(failures) critical = len(failures)
verb_histogram = {
verb: {
"total": sum(counter.values()),
"buckets": dict(counter),
}
for verb, counter in sorted(by_verb.items())
}
return { return {
"major": major, "major": major,
"version": snapshot.get("source_version") or version, "version": snapshot.get("source_version") or version,
"apply": applied, "apply": applied,
"declared": declared, "declared": declared,
"probed": probed, "probed": probed,
"head_probed": len(head_results),
"by_verb": {verb: dict(counter) for verb, counter in by_verb.items()}, "by_verb": {verb: dict(counter) for verb, counter in by_verb.items()},
"verb_histogram": verb_histogram,
"success_2xx": success_2xx, "success_2xx": success_2xx,
"client_4xx": sum( "client_4xx": sum(
c.get("client_4xx", 0) + c.get("auth_401_403", 0) for c in by_verb.values() c.get("client_4xx", 0) + c.get("auth_401_403", 0) for c in by_verb.values()
@@ -325,6 +476,7 @@ def probe_major(
"ok": critical == 0, "ok": critical == 0,
"time": time.monotonic() - started, "time": time.monotonic() - started,
"methods": method_results, "methods": method_results,
"head_methods": head_results,
} }
+41
View File
@@ -73,6 +73,33 @@ def _surface_error(major: dict[str, Any]) -> str:
return f"{len(fails)} critical: " + "; ".join(parts) return f"{len(fails)} critical: " + "; ".join(parts)
def _verb_histogram_rows(surface: list[dict[str, Any]]) -> list[str]:
rows: list[str] = []
for major in surface:
histogram = major.get("verb_histogram") or {}
if not histogram:
# Fall back to by_verb totals when slim payload lacks histogram.
by_verb = major.get("by_verb") or {}
histogram = {
verb: {"total": sum(buckets.values()), "buckets": buckets}
for verb, buckets in by_verb.items()
}
for verb, info in sorted(histogram.items()):
buckets = info.get("buckets") or {}
bucket_txt = ", ".join(
f"{name}={count}" for name, count in sorted(buckets.items()) if count
)
rows.append(
"<tr>"
f"<td>{html.escape(str(major.get('version') or major.get('major')))}</td>"
f"<td>{html.escape(str(verb))}</td>"
f"<td>{html.escape(str(info.get('total') or 0))}</td>"
f"<td><code>{html.escape(bucket_txt)}</code></td>"
"</tr>"
)
return rows
def write_html(payload: dict[str, Any], path: Path) -> None: def write_html(payload: dict[str, Any], path: Path) -> None:
surface = payload.get("surface") or [] surface = payload.get("surface") or []
scenarios = payload.get("scenarios") or [] scenarios = payload.get("scenarios") or []
@@ -240,6 +267,20 @@ code {{ font-family: var(--mono); font-size: .85em; }}
</tbody> </tbody>
</table> </table>
<h2>Verb histogram (incl. synthetic HEAD)</h2>
<p class="lead">
Contract verbs GET/PUT/POST/DELETE count toward coverage.
HEAD is probed on every GET path for the matrix but is not part of declared/probed.
</p>
<table>
<thead><tr>
<th>Major</th><th>Verb</th><th>Total</th><th>Buckets</th>
</tr></thead>
<tbody>
{''.join(_verb_histogram_rows(surface)) or '<tr><td colspan="4" class="empty">No histogram</td></tr>'}
</tbody>
</table>
<h2>Critical surface failures</h2> <h2>Critical surface failures</h2>
<table> <table>
<thead><tr> <thead><tr>
+32 -16
View File
@@ -22,9 +22,10 @@ sys.path.insert(0, str(ROOT))
def run_lifecycle(*, smoke: bool) -> dict[str, Any]: def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
from pulumi import automation as auto from pulumi import automation as auto
import uuid
program_dir = PROGRAMS / "lifecycle" program_dir = PROGRAMS / "lifecycle"
stack_name = f"hxlife{os.getpid()}{int(time.time())}" stack_name = f"hxlife-{uuid.uuid4().hex[:12]}"
os.environ.setdefault("PULUMI_CONFIG_PASSPHRASE", "hx-test-passphrase") os.environ.setdefault("PULUMI_CONFIG_PASSPHRASE", "hx-test-passphrase")
state = HERE / ".pulumi-state" state = HERE / ".pulumi-state"
state.mkdir(parents=True, exist_ok=True) state.mkdir(parents=True, exist_ok=True)
@@ -43,6 +44,21 @@ def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
started = time.monotonic() started = time.monotonic()
stack = None stack = None
def _cleanup() -> None:
if stack is None:
return
try:
stack.destroy(on_output=lambda _: None)
except Exception as exc: # noqa: BLE001
message = str(exc).lower()
if "no stack named" not in message and "not found" not in message:
raise
try:
stack.workspace.remove_stack(stack_name)
except Exception:
pass
try: try:
# Pass env (incl. PULUMI_BACKEND_URL) at workspace creation — assigning # Pass env (incl. PULUMI_BACKEND_URL) at workspace creation — assigning
# workspace.env_vars after create_or_select_stack can lose stack selection. # workspace.env_vars after create_or_select_stack can lose stack selection.
@@ -67,11 +83,7 @@ def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
item = outputs.get(key) item = outputs.get(key)
if item is None or getattr(item, "value", None) in (None, "", {}, []): if item is None or getattr(item, "value", None) in (None, "", {}, []):
raise RuntimeError(f"lifecycle export {key!r} is empty") raise RuntimeError(f"lifecycle export {key!r} is empty")
stack.destroy(on_output=lambda _: None) _cleanup()
try:
stack.workspace.remove_stack(stack_name)
except Exception:
pass
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
if ids: if ids:
return { return {
@@ -89,15 +101,10 @@ def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
stderr = getattr(exc, "stderr", None) stderr = getattr(exc, "stderr", None)
if stderr: if stderr:
detail = f"{detail}\n{stderr}" detail = f"{detail}\n{stderr}"
if stack is not None:
try: try:
stack.destroy(on_output=lambda _: None) _cleanup()
except Exception: except Exception as cleanup_exc: # noqa: BLE001
pass detail = f"{detail}\ncleanup: {cleanup_exc}"
try:
stack.workspace.remove_stack(stack_name)
except Exception:
pass
return { return {
"ok": False, "ok": False,
"scenarios": [ "scenarios": [
@@ -166,9 +173,17 @@ def main() -> int:
status = "ok" if item["ok"] else "FAIL" status = "ok" if item["ok"] else "FAIL"
print( print(
f" {status} PVE {item['version']}: declared={item['declared']} " f" {status} PVE {item['version']}: declared={item['declared']} "
f"probed={item['probed']} critical={item['failure_count']} " f"probed={item['probed']} head={item.get('head_probed', 0)} "
f"critical={item['failure_count']} "
f"2xx={item['success_2xx']} 4xx={item['client_4xx']}" f"2xx={item['success_2xx']} 4xx={item['client_4xx']}"
) )
hist = item.get("verb_histogram") or {}
if hist:
parts = [
f"{verb}:{info.get('total', 0)}"
for verb, info in sorted(hist.items())
]
print(f" verbs: {', '.join(parts)}")
if not item["ok"]: if not item["ok"]:
for fail in (item.get("failures") or [])[:10]: for fail in (item.get("failures") or [])[:10]:
print( print(
@@ -177,11 +192,12 @@ def main() -> int:
file=sys.stderr, file=sys.stderr,
) )
# Slim JSON: drop full method lists (keep failures) # Slim JSON: drop full method lists (keep failures + verb histogram)
surface_slim = [] surface_slim = []
for item in surface: for item in surface:
slim = dict(item) slim = dict(item)
slim.pop("methods", None) slim.pop("methods", None)
slim.pop("head_methods", None)
surface_slim.append(slim) surface_slim.append(slim)
coverage = build_coverage(surface_slim, majors=list(majors)) coverage = build_coverage(surface_slim, majors=list(majors))
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Generate a gitignored docker-compose.override.yml (busy host ports → free ones)
# and start the local stack. Invoked by `make up-local`.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
COMPOSE="${COMPOSE:-docker compose}"
OVERRIDE="${LOCAL_OVERRIDE:-docker-compose.override.yml}"
PREFERRED_SIMULATOR="${SIMULATOR_PORT:-8006}"
PREFERRED_POSTGRES="${POSTGRES_HOST_PORT:-5432}"
PREFERRED_TLS="${TLS_GATEWAY_PORT:-8443}"
port_in_use() {
local port="$1"
if command -v nc >/dev/null 2>&1; then
nc -z 127.0.0.1 "$port" >/dev/null 2>&1
return $?
fi
python3 - "$port" <<'PY'
import socket, sys
port = int(sys.argv[1])
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.25)
try:
sys.exit(0 if s.connect_ex(("127.0.0.1", port)) == 0 else 1)
finally:
s.close()
PY
}
ours_on_port() {
local port="$1"
# Host publish lines look like: 0.0.0.0:8006->8006/tcp or 127.0.0.1:5432->5432/tcp
$COMPOSE ps --format '{{.Ports}}' 2>/dev/null \
| grep -E "(^|[, ])(([0-9.]+|\[::\]):)?${port}->" >/dev/null 2>&1
}
pick_port() {
local preferred="$1"
local label="$2"
local max=$((preferred + 100))
local p="$preferred"
while [ "$p" -le "$max" ]; do
if ! port_in_use "$p" || ours_on_port "$p"; then
if [ "$p" -ne "$preferred" ]; then
echo "Port ${preferred} busy for ${label} → using ${p}" >&2
fi
echo "$p"
return 0
fi
p=$((p + 1))
done
echo "No free host port near ${preferred} for ${label}" >&2
exit 1
}
SIMULATOR_HOST_PORT="$(pick_port "$PREFERRED_SIMULATOR" "simulator")"
POSTGRES_HOST_PORT="$(pick_port "$PREFERRED_POSTGRES" "postgres")"
TLS_HOST_PORT="$(pick_port "$PREFERRED_TLS" "tls-gateway")"
cat >"$OVERRIDE" <<EOF
# Generated by scripts/up-local.sh / \`make up-local\`.
# Gitignored — do not commit. Compose loads this file automatically.
services:
postgres:
ports: !override
- "127.0.0.1:${POSTGRES_HOST_PORT}:5432"
simulator:
ports: !override
- "${SIMULATOR_HOST_PORT}:8006"
tls-gateway:
ports: !override
- "${TLS_HOST_PORT}:8443"
EOF
echo "Wrote ${OVERRIDE}:"
echo " simulator → http://localhost:${SIMULATOR_HOST_PORT}/"
echo " postgres → 127.0.0.1:${POSTGRES_HOST_PORT}"
echo " tls-gateway→ https://localhost:${TLS_HOST_PORT}/ (profile tls)"
test -f .env || cp .env.example .env
$COMPOSE up -d --build --wait
echo
echo "Local stack is up: http://localhost:${SIMULATOR_HOST_PORT}/"
echo "Health: http://localhost:${SIMULATOR_HOST_PORT}/health/ready"
+2 -2
View File
@@ -22,7 +22,7 @@ from app.config import Settings
from app.db.migrations import migrate from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase from app.db.pool import AsyncpgDatabase
from app.main import create_app from app.main import create_app
from app.simulation.seed import apply_seed, small_profile from app.simulation.seed import apply_seed, lab_profile
pytestmark = [ pytestmark = [
pytest.mark.integration, pytest.mark.integration,
@@ -40,7 +40,7 @@ async def _prepare_database(url: str) -> None:
connection = await asyncpg.connect(url) connection = await asyncpg.connect(url)
try: try:
await migrate(connection) await migrate(connection)
await apply_seed(connection, small_profile()) await apply_seed(connection, lab_profile())
finally: finally:
await connection.close() await connection.close()
+2 -1
View File
@@ -116,7 +116,8 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update" assert proxmox.nodes("pve1").qemu("150").config.get()["name"] == "async-update"
disk_api = proxmox.nodes("pve1").qemu("150") disk_api = proxmox.nodes("pve1").qemu("150")
assert disk_api.resize.put(disk="scsi0", size="+2G") is None resize_upid = disk_api.resize.put(disk="scsi0", size="+2G")
assert wait_task(proxmox, resize_upid)["exitstatus"] == "OK"
assert "size=10G" in disk_api.config.get()["scsi0"] assert "size=10G" in disk_api.config.get()["scsi0"]
move_upid = disk_api.move_disk.post(disk="scsi0", storage="shared") move_upid = disk_api.move_disk.post(disk="scsi0", storage="shared")
assert wait_task(proxmox, move_upid)["exitstatus"] == "OK" assert wait_task(proxmox, move_upid)["exitstatus"] == "OK"
+6 -6
View File
@@ -11,7 +11,7 @@ from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError from app.db.primitives import ConflictError
from app.db.repositories.resources import ResourceRepository from app.db.repositories.resources import ResourceRepository
from app.simulation.seed import apply_seed, small_profile from app.simulation.seed import apply_seed, lab_profile
pytestmark = [ pytestmark = [
pytest.mark.integration, pytest.mark.integration,
@@ -45,8 +45,8 @@ async def test_small_seed_is_idempotent() -> None:
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"]) connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
try: try:
await migrate(connection) await migrate(connection)
await apply_seed(connection, small_profile()) await apply_seed(connection, lab_profile())
await apply_seed(connection, small_profile()) await apply_seed(connection, lab_profile())
assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve01'") == 1 assert await connection.fetchval("SELECT count(*) FROM nodes WHERE name = 'pve01'") == 1
assert ( assert (
await connection.fetchval( await connection.fetchval(
@@ -58,8 +58,8 @@ async def test_small_seed_is_idempotent() -> None:
assert await connection.fetchval("SELECT count(*) FROM tasks WHERE status = 'success'") == 2 assert await connection.fetchval("SELECT count(*) FROM tasks WHERE status = 'success'") == 2
assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 2 assert await connection.fetchval("SELECT count(*) FROM virtual_machines") == 2
assert await connection.fetchval("SELECT count(*) FROM containers") == 1 assert await connection.fetchval("SELECT count(*) FROM containers") == 1
assert await connection.fetchval("SELECT count(*) FROM storages") == 2 assert await connection.fetchval("SELECT count(*) FROM storages") == 3
assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 4 assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 7
assert await connection.fetchval("SELECT count(*) FROM identity_groups") == 1 assert await connection.fetchval("SELECT count(*) FROM identity_groups") == 1
assert await connection.fetchval("SELECT count(*) FROM identity_group_members") == 1 assert await connection.fetchval("SELECT count(*) FROM identity_group_members") == 1
assert await connection.fetchval("SELECT count(*) FROM group_acl_entries") == 1 assert await connection.fetchval("SELECT count(*) FROM group_acl_entries") == 1
@@ -105,7 +105,7 @@ async def test_schema_readiness_and_optimistic_resource_repository() -> None:
database = AsyncpgDatabase(Settings(database_url=url)) database = AsyncpgDatabase(Settings(database_url=url))
try: try:
await migrate(connection) await migrate(connection)
await apply_seed(connection, small_profile()) await apply_seed(connection, lab_profile())
await database.connect() await database.connect()
assert await database.is_ready() assert await database.is_ready()
+126
View File
@@ -0,0 +1,126 @@
"""Cluster backup job handlers."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.handlers.backup import register_backup_handlers
class BackupPool:
def __init__(self) -> None:
self.metadata: dict[str, Any] = {
"backup_jobs": {
"backup-daily": {
"id": "backup-daily",
"schedule": "0 2 * * *",
"storage": "local",
"enabled": 1,
"vmid": "100",
}
}
}
self.guests = [
{
"external_id": "100",
"kind": "qemu",
"state": '{"name":"demo"}',
"qemu_config": '{"scsi0":"local-lvm:vm-100-disk-0,size=32G"}',
"lxc_config": None,
},
{
"external_id": "200",
"kind": "lxc",
"state": '{"name":"service"}',
"qemu_config": None,
"lxc_config": '{"rootfs":"local-lvm:vm-200-disk-0,size=8G"}',
},
]
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query)
async def fetch(self, query: str, *arguments: object) -> list[dict[str, Any]]:
if "FROM resources r" in query and "ANY($1" in query:
selected = set(str(item) for item in cast(list[Any], arguments[0]))
return [row for row in self.guests if row["external_id"] in selected]
if "FROM resources r" in query:
return list(self.guests)
raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str:
if "UPDATE clusters SET metadata" in query:
self.metadata = json.loads(str(arguments[1]))
return "UPDATE 1"
raise AssertionError(query)
def _request(pool: BackupPool) -> Request:
app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request(
{
"type": "http",
"app": app,
"method": "POST",
"path": "/",
"headers": [],
"query_string": b"",
"server": ("test", 80),
"client": ("test", 123),
"scheme": "http",
}
)
async def test_backup_jobs_crud_and_shapes() -> None:
registry = HandlerRegistry()
register_backup_handlers(registry)
pool = BackupPool()
http = _request(pool)
listed = await registry.get("/cluster/backup", "GET")(http, {"values": {}})
assert listed[0]["id"] == "backup-daily"
assert listed[0]["schedule"] == "0 2 * * *"
await registry.get("/cluster/backup", "POST")(
http,
{
"values": {
"id": "backup-weekly",
"schedule": "@weekly",
"storage": "local",
"vmid": "200",
}
},
)
assert "backup-weekly" in pool.metadata["backup_jobs"]
info = await registry.get("/cluster/backup-info", "GET")(http, {"values": {}})
assert info == [{"subdir": "not-backed-up"}]
missing = await registry.get("/cluster/backup-info/not-backed-up", "GET")(http, {"values": {}})
assert missing == [] # 100 covered by daily; after weekly both covered? weekly adds 200
# daily covers 100, weekly covers 200 → none missing
assert missing == []
included = await registry.get("/cluster/backup/{id}/included_volumes", "GET")(
http, {"values": {"id": "backup-daily"}}
)
assert included["children"][0]["id"] == 100
assert included["children"][0]["children"][0]["id"] == "scsi0"
await registry.get("/cluster/backup/{id}", "DELETE")(http, {"values": {"id": "backup-weekly"}})
assert "backup-weekly" not in pool.metadata["backup_jobs"]
missing_after = await registry.get("/cluster/backup-info/not-backed-up", "GET")(
http, {"values": {}}
)
assert missing_after[0]["vmid"] == 200
assert missing_after[0]["type"] == "lxc"
+1 -1
View File
@@ -125,7 +125,7 @@ async def test_ceph_pool_and_osd_mutations_persist() -> None:
await create_pool(http, {"values": {"node": "pve1", "name": "vms"}, "provided": frozenset()}) await create_pool(http, {"values": {"node": "pve1", "name": "vms"}, "provided": frozenset()})
pools = await list_pool(http, {"values": {"node": "pve1"}, "provided": frozenset()}) pools = await list_pool(http, {"values": {"node": "pve1"}, "provided": frozenset()})
assert any(item["pool"] == "vms" for item in pools) assert any(item.get("pool_name") == "vms" for item in pools)
assert "vms" in pool.cluster_metadata["ceph"]["pools"] assert "vms" in pool.cluster_metadata["ceph"]["pools"]
await create_osd(http, {"values": {"node": "pve1", "dev": "/dev/sdb"}, "provided": frozenset()}) await create_osd(http, {"values": {"node": "pve1", "dev": "/dev/sdb"}, "provided": frozenset()})
+217 -5
View File
@@ -6,8 +6,10 @@ import json
from typing import Any, cast from typing import Any, cast
from uuid import uuid4 from uuid import uuid4
import pytest
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase from app.db.pool import AsyncpgDatabase
from app.handlers.acme import register_acme_handlers from app.handlers.acme import register_acme_handlers
@@ -34,6 +36,8 @@ class MetaPool:
async def fetchval(self, query: str, *arguments: object) -> Any: async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query: if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes return str(arguments[0]) in self.nodes
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in query:
return sorted(self.nodes)[0] if self.nodes else None
raise AssertionError(query) raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str: async def execute(self, query: str, *arguments: object) -> str:
@@ -52,6 +56,16 @@ class MetaPool:
raise AssertionError(query) raise AssertionError(query)
class FakeTaskRepository:
def __init__(self, pool: MetaPool) -> None:
self.pool = pool
self.created: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> Any:
self.created.append(kwargs)
return type("Task", (), {"upid": kwargs["upid"]})()
async def call( async def call(
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any] registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
) -> Any: ) -> Any:
@@ -63,7 +77,7 @@ async def call(
def request(pool: MetaPool) -> Request: def request(pool: MetaPool) -> Request:
app = FastAPI() app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})()) app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request( http = Request(
{ {
"type": "http", "type": "http",
"app": app, "app": app,
@@ -76,15 +90,19 @@ def request(pool: MetaPool) -> Request:
"scheme": "http", "scheme": "http",
} }
) )
http.state.principal = "root@pam"
return http
async def test_mapping_acme_config_persist() -> None: async def test_mapping_acme_config_persist(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry() registry = HandlerRegistry()
register_mapping_handlers(registry) register_mapping_handlers(registry)
register_acme_handlers(registry) register_acme_handlers(registry)
register_cluster_config_handlers(registry) register_cluster_config_handlers(registry)
pool = MetaPool() pool = MetaPool()
http = request(pool) http = request(pool)
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.cluster_config.TaskRepository", lambda _pool: repository)
await call( await call(
registry, registry,
@@ -119,20 +137,214 @@ async def test_mapping_acme_config_persist() -> None:
http, http,
{"values": {"name": "default"}, "provided": frozenset()}, {"values": {"name": "default"}, "provided": frozenset()},
) )
assert account["name"] == "default" assert account["account"]["name"] == "default"
assert account["directory"]
assert "eab-hmac-key" not in account assert "eab-hmac-key" not in account
assert "eab-hmac-key" not in account["account"]
await call( upid = await call(
registry, registry,
"/cluster/config", "/cluster/config",
"POST", "POST",
http, http,
{"values": {"clustername": "lab"}, "provided": frozenset()}, {"values": {"clustername": "lab", "link0": "10.0.0.1"}, "provided": frozenset()},
) )
assert isinstance(upid, str)
assert upid.startswith("UPID:pve1:")
assert ":clustercreate:" in upid
assert repository.created[0]["task_type"] == "cluster-create"
assert pool.metadata["cluster_config"]["clustername"] == "lab" assert pool.metadata["cluster_config"]["clustername"] == "lab"
assert pool.cluster_name == "lab" assert pool.cluster_name == "lab"
assert pool.metadata["cluster_config"]["corosync_conf"]
assert pool.metadata["cluster_config"]["config_digest"]
totem = await call( totem = await call(
registry, "/cluster/config/totem", "GET", http, {"values": {}, "provided": frozenset()} registry, "/cluster/config/totem", "GET", http, {"values": {}, "provided": frozenset()}
) )
assert totem["cluster_name"] == "lab" assert totem["cluster_name"] == "lab"
assert uuid4() assert uuid4()
async def test_cluster_config_index_uses_name_links() -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
index = await call(
registry, "/cluster/config", "GET", request(pool), {"values": {}, "provided": frozenset()}
)
assert index == [
{"name": "nodes"},
{"name": "totem"},
{"name": "join"},
{"name": "qdevice"},
{"name": "apiversion"},
]
async def test_cluster_join_info_shape_and_stable_digest(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
monkeypatch.setattr(
"app.handlers.cluster_config.TaskRepository",
lambda _pool: FakeTaskRepository(pool),
)
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "lab", "link0": "10.0.0.1"}, "provided": frozenset()},
)
first = await call(
registry,
"/cluster/config/join",
"GET",
http,
{"values": {}, "provided": frozenset()},
)
second = await call(
registry,
"/cluster/config/join",
"GET",
http,
{"values": {"node": "pve1"}, "provided": frozenset()},
)
assert set(first) == {"nodelist", "preferred_node", "totem", "config_digest"}
assert first["config_digest"] == second["config_digest"]
assert first["preferred_node"] == "pve1"
defaulted = await call(
registry,
"/cluster/config/join",
"GET",
http,
{
"values": {"node": "current connected node"},
"provided": frozenset(),
},
)
assert defaulted["preferred_node"] == "pve1"
node = first["nodelist"][0]
assert node["name"] == "pve1"
assert node["pve_addr"]
assert node["ring0_addr"]
assert node["pve_fp"].count(":") == 31
assert "password" not in json.dumps(first)
async def test_cluster_join_returns_upid(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.cluster_config.TaskRepository", lambda _pool: repository)
fingerprint = ":".join(["AB"] * 32)
upid = await call(
registry,
"/cluster/config/join",
"POST",
http,
{
"values": {
"hostname": "10.0.0.1",
"fingerprint": fingerprint,
"password": "secret",
},
"provided": frozenset(),
},
)
assert upid.startswith("UPID:")
assert ":clusterjoin:" in upid
assert repository.created[0]["task_type"] == "cluster-join"
join = pool.metadata["cluster_config"]["join_info"]["10.0.0.1"]
assert join["password_set"] is True
assert "password" not in join
async def test_cluster_join_requires_contract_params() -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
with pytest.raises(ApiError, match="hostname"):
await call(
registry,
"/cluster/config/join",
"POST",
http,
{"values": {"password": "x", "fingerprint": "y"}, "provided": frozenset()},
)
async def test_cluster_create_rejects_existing_corosync(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
monkeypatch.setattr(
"app.handlers.cluster_config.TaskRepository",
lambda _pool: FakeTaskRepository(pool),
)
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "lab", "link0": "10.0.0.1"}, "provided": frozenset()},
)
with pytest.raises(ApiError, match="cluster config already exists"):
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "other"}, "provided": frozenset()},
)
async def test_cluster_addnode_returns_corosync_object(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = HandlerRegistry()
register_cluster_config_handlers(registry)
pool = MetaPool()
http = request(pool)
monkeypatch.setattr(
"app.handlers.cluster_config.TaskRepository",
lambda _pool: FakeTaskRepository(pool),
)
await call(
registry,
"/cluster/config",
"POST",
http,
{"values": {"clustername": "lab", "link0": "10.0.0.1"}, "provided": frozenset()},
)
result = await call(
registry,
"/cluster/config/nodes/{node}",
"POST",
http,
{
"values": {"node": "pve2", "new_node_ip": "10.0.0.2", "votes": 1},
"provided": frozenset(),
},
)
assert set(result) == {"corosync_authkey", "corosync_conf", "warnings"}
assert "nodelist" in result["corosync_conf"]
assert "pve2" in result["corosync_conf"]
assert result["warnings"] == []
assert "pve2" in pool.nodes
nodes = await call(
registry,
"/cluster/config/nodes",
"GET",
http,
{"values": {}, "provided": frozenset()},
)
assert {item["node"] for item in nodes} == {"pve1", "pve2"}
+68
View File
@@ -88,6 +88,74 @@ def test_method_payload_builds_examples() -> None:
assert payload["resolved_path"] == "/nodes/pve01/qemu" assert payload["resolved_path"] == "/nodes/pve01/qemu"
assert payload["body_example"] == {"vmid": 100, "name": "example"} assert payload["body_example"] == {"vmid": 100, "name": "example"}
assert payload["implemented"] is True assert payload["implemented"] is True
indexed = cast(list[dict[str, Any]], payload["indexed_fields"])
assert indexed[0]["name"] == "scsi0"
assert indexed[0]["template"] == "scsi[n]"
assert indexed[0]["optional"] is True
def test_method_payload_flattens_nested_body_example_into_params() -> None:
method = Method(
verb="POST",
name="create_mapping",
description="Create mapping.",
parameters=(
Parameter(name="id", definition=Schema(type="string")),
Parameter(
name="map",
definition=Schema(
type="array",
items=Schema(type="string"),
),
),
Parameter(name="comment", definition=Schema(type="string", optional=True)),
),
returns=Schema(type="null"),
checksum="c" * 64,
)
snapshot = Snapshot(
source_version="9.2.3",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="d" * 64,
paths=(PathContract(path="/cluster/mapping/dir", methods=(method,)),),
path_count=1,
method_count=1,
)
payload = method_payload(
snapshot,
major=9,
path="/cluster/mapping/dir",
verb="POST",
runtime_version="9.2.3",
implemented_methods=None,
)
assert payload["body_example"] == {"id": "example", "map": ["example"]}
names = [str(item["name"]) for item in cast(list[dict[str, Any]], payload["body_fields"])]
assert "id" in names
assert "map.0" in names
assert "map" not in names
assert "comment" in names
@pytest.mark.asyncio
async def test_method_payload_property_string_examples_from_bundled_contract() -> None:
from app.web import contract_catalog
contract_catalog._SNAPSHOT_CACHE.clear()
snapshot = await contract_catalog.load_snapshot(8, Path("contracts"))
payload = method_payload(
snapshot,
major=8,
path="/cluster/config",
verb="POST",
runtime_version="8.4.5",
implemented_methods=None,
)
assert payload["body_example"] == {"clustername": "example"}
indexed = cast(list[dict[str, Any]], payload["indexed_fields"])
link0 = next(item for item in indexed if item["name"] == "link0")
assert link0["example"] == "192.168.0.1"
assert link0["typetext"] == "[address=]<IP> [,priority=<integer>]"
@pytest.mark.asyncio @pytest.mark.asyncio
+21 -5
View File
@@ -39,20 +39,33 @@ class FakePool:
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]: async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args del args
if "FROM pool_members" in sql or "kind='ha'" in sql:
return []
if "FROM nodes" in sql: if "FROM nodes" in sql:
return [{"node": "pve1", "status": "online"}] return [{"node": "pve1", "name": "pve1", "status": "online"}]
if "r.kind='qemu'" in sql: if "r.kind='qemu'" in sql:
return [{"vmid": 100, "state": '{"name":"demo","status":"stopped"}'}] return [
{
"vmid": 100,
"state": '{"name":"demo","status":"stopped"}',
"config": '{"name":"demo","memory":2048,"cores":2}',
}
]
if "r.kind = ANY" in sql or "r.kind AS type" in sql:
return [ return [
{ {
"type": "qemu", "type": "qemu",
"external_id": "100", "external_id": "100",
"state": '{"status":"stopped"}', "state": '{"status":"stopped","name":"demo"}',
"node": "pve1", "node": "pve1",
} }
] ]
return []
async def fetchval(self, sql: str) -> int: async def fetchval(self, sql: str, *args: object) -> object:
del args
if "SELECT name FROM clusters" in sql:
return "pve-simulator"
return 100 if "pg_backend_pid" in sql else 1_700_000_000 return 100 if "pg_backend_pid" in sql else 1_700_000_000
@@ -194,10 +207,13 @@ async def test_core_login_and_read_endpoints(
assert login.status_code == 200 assert login.status_code == 200
assert login.json()["data"]["username"] == "root@pam" assert login.json()["data"]["username"] == "root@pam"
assert "ticket" in login.json()["data"] assert "ticket" in login.json()["data"]
assert login.json()["data"]["clustername"] == "pve-simulator"
assert version.json()["data"]["version"] == "test" assert version.json()["data"]["version"] == "test"
assert version.json()["data"]["release"] == "test" assert version.json()["data"]["release"] == "test"
assert nodes.json()["data"][0]["node"] == "pve1" assert nodes.json()["data"][0]["node"] == "pve1"
assert status.json()["data"]["status"] == "online" assert "uptime" in status.json()["data"]
assert "memory" in status.json()["data"]
assert "cpuinfo" in status.json()["data"]
resource_types = {item["type"] for item in resources.json()["data"]} resource_types = {item["type"] for item in resources.json()["data"]}
assert "qemu" in resource_types assert "qemu" in resource_types
qemu_resources = [item for item in resources.json()["data"] if item["type"] == "qemu"] qemu_resources = [item for item in resources.json()["data"] if item["type"] == "qemu"]
+20 -4
View File
@@ -42,6 +42,7 @@ class HandlerPool:
{ {
"external_id": "osd.0", "external_id": "osd.0",
"state": '{"osd_id":0,"status":"up","in":true,"weight":1.0}', "state": '{"osd_id":0,"status":"up","in":true,"weight":1.0}',
"node": "pve01",
} }
] ]
if "FROM pools" in sql: if "FROM pools" in sql:
@@ -54,7 +55,14 @@ class HandlerPool:
} }
] ]
if "FROM pool_members" in sql: if "FROM pool_members" in sql:
return [{"external_id": "100"}] return [
{
"external_id": "100",
"kind": "qemu",
"state": '{"name":"demo","status":"stopped"}',
"node": "pve01",
}
]
if "FROM task_logs" in sql: if "FROM task_logs" in sql:
return [{"message": "seeded task", "sequence": 1}] return [{"message": "seeded task", "sequence": 1}]
if "FROM tasks" in sql: if "FROM tasks" in sql:
@@ -101,6 +109,8 @@ class HandlerPool:
return 150 return 150
if "count(*)::int FROM resources WHERE kind='ceph-osd'" in sql: if "count(*)::int FROM resources WHERE kind='ceph-osd'" in sql:
return 300 return 300
if "COUNT(*) FROM nodes" in sql or "count(*) FROM nodes" in sql:
return 1
if "SELECT resource_id FROM storages" in sql: if "SELECT resource_id FROM storages" in sql:
return uuid.uuid4() return uuid.uuid4()
return False return False
@@ -139,7 +149,10 @@ async def test_cluster_status_and_nextid() -> None:
registry = HandlerRegistry() registry = HandlerRegistry()
register_cluster_handlers(registry) register_cluster_handlers(registry)
status = await _call(registry.get("/cluster/status", "GET"), {}) status = await _call(registry.get("/cluster/status", "GET"), {})
assert status[0]["name"] == "pve01" assert status[0]["type"] == "cluster"
assert status[0]["quorate"] is True
assert status[1]["name"] == "pve01"
assert status[1]["online"] is True
nextid = await _call(registry.get("/cluster/nextid", "GET"), {}) nextid = await _call(registry.get("/cluster/nextid", "GET"), {})
assert nextid == 151 assert nextid == 151
@@ -158,7 +171,8 @@ async def test_storage_and_ceph_handlers() -> None:
registry.get("/nodes/{node}/ceph/osd", "GET"), registry.get("/nodes/{node}/ceph/osd", "GET"),
{"node": "pve01"}, {"node": "pve01"},
) )
assert osds[0]["status"] == "up" assert osds["root"]["type"] == "root"
assert osds["root"]["children"][0]["children"][0]["status"] == "up"
ceph_status = await _call(registry.get("/cluster/ceph/status", "GET"), {}) ceph_status = await _call(registry.get("/cluster/ceph/status", "GET"), {})
assert ceph_status["osdmap"]["num_osds"] == 1 assert ceph_status["osdmap"]["num_osds"] == 1
@@ -169,7 +183,9 @@ async def test_pools_list() -> None:
register_pool_handlers(registry) register_pool_handlers(registry)
pools = await _call(registry.get("/pools", "GET"), {}) pools = await _call(registry.get("/pools", "GET"), {})
assert pools[0]["poolid"] == "production" assert pools[0]["poolid"] == "production"
assert pools[0]["members"] == ["100"] assert pools[0]["members"][0]["vmid"] == 100
assert pools[0]["members"][0]["type"] == "qemu"
assert pools[0]["members"][0]["node"] == "pve01"
@pytest.mark.asyncio @pytest.mark.asyncio
+23 -4
View File
@@ -32,7 +32,6 @@ class GapPool:
self.groups = {"operators": {"comment": "ops", "users": ["root@pam"]}} self.groups = {"operators": {"comment": "ops", "users": ["root@pam"]}}
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]: async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM nodes" in sql and "ORDER BY name" in sql: if "FROM nodes" in sql and "ORDER BY name" in sql:
return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}] return [{"id": uuid.uuid4(), "name": "pve01", "status": "online"}]
if "FROM tasks" in sql: if "FROM tasks" in sql:
@@ -64,6 +63,17 @@ class GapPool:
} }
for group_id, data in self.groups.items() for group_id, data in self.groups.items()
] ]
if "FROM identity_group_members" in sql:
userid = str(args[0]) if args else ""
return [
{"group_id": group_id}
for group_id, data in self.groups.items()
if userid in data["users"]
]
if "FROM api_tokens" in sql:
return []
if "FROM resources r JOIN nodes" in sql and "kind='ha'" in sql:
return []
raise AssertionError(sql) raise AssertionError(sql)
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None: async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
@@ -149,6 +159,8 @@ class GapPool:
return json.dumps(self.node_metadata.get(str(args[0]), {})) return json.dumps(self.node_metadata.get(str(args[0]), {}))
if "SELECT name FROM nodes WHERE status" in sql: if "SELECT name FROM nodes WHERE status" in sql:
return "pve01" return "pve01"
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in sql:
return "pve01"
return False return False
async def execute(self, sql: str, *args: object) -> str: async def execute(self, sql: str, *args: object) -> str:
@@ -217,7 +229,7 @@ async def test_cluster_index_and_replication_crud() -> None:
) )
assert created["id"] == "repl-100" assert created["id"] == "repl-100"
jobs = await _call(registry.get("/cluster/replication", "GET"), {}, pool) jobs = await _call(registry.get("/cluster/replication", "GET"), {}, pool)
assert jobs[0]["guest"] == "100" assert jobs[0]["guest"] == 100
fetched = await _call( fetched = await _call(
registry.get("/cluster/replication/{id}", "GET"), {"id": "repl-100"}, pool registry.get("/cluster/replication/{id}", "GET"), {"id": "repl-100"}, pool
) )
@@ -257,10 +269,16 @@ async def test_access_user_and_group_detail() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_storage_content_get_and_upload() -> None: async def test_storage_content_get_and_upload(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry() registry = HandlerRegistry()
register_storage_handlers(registry) register_storage_handlers(registry)
pool = GapPool() pool = GapPool()
class FakeTaskRepository:
async def create(self, **kwargs: Any) -> Any:
return type("Task", (), {"upid": kwargs["upid"]})()
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: FakeTaskRepository())
item = await _call( item = await _call(
registry.get("/nodes/{node}/storage/{storage}/content/{volume}", "GET"), registry.get("/nodes/{node}/storage/{storage}/content/{volume}", "GET"),
{ {
@@ -276,7 +294,8 @@ async def test_storage_content_get_and_upload() -> None:
{"node": "pve01", "storage": "local-lvm", "filename": "image.iso"}, {"node": "pve01", "storage": "local-lvm", "filename": "image.iso"},
pool, pool,
) )
assert "uploadid" in upload assert isinstance(upload, str) and upload.startswith("UPID:")
assert any("image.iso" in str(item["volume_id"]) for item in pool.storage_contents)
@pytest.mark.asyncio @pytest.mark.asyncio
+11 -2
View File
@@ -28,8 +28,11 @@ class GapRemainingPool:
raise AssertionError(query) raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any: async def fetchval(self, query: str, *arguments: object) -> Any:
del arguments
if "EXISTS(SELECT 1 FROM nodes" in query: if "EXISTS(SELECT 1 FROM nodes" in query:
return True return True
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in query:
return "pve01"
raise AssertionError(query) raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str: async def execute(self, query: str, *arguments: object) -> str:
@@ -68,10 +71,16 @@ def _request(pool: GapRemainingPool, *, method: str = "GET") -> Request:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_disks_directory_create_persists() -> None: async def test_disks_directory_create_persists(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry() registry = HandlerRegistry()
register_nodes_extra_handlers(registry) register_nodes_extra_handlers(registry)
pool = GapRemainingPool() pool = GapRemainingPool()
class FakeTaskRepository:
async def create(self, **kwargs: Any) -> Any:
return type("Task", (), {"upid": kwargs["upid"]})()
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: FakeTaskRepository())
create = registry.get("/nodes/{node}/disks/directory", "POST") create = registry.get("/nodes/{node}/disks/directory", "POST")
assert create is not None assert create is not None
created = await create( created = await create(
@@ -81,7 +90,7 @@ async def test_disks_directory_create_persists() -> None:
"provided": frozenset(), "provided": frozenset(),
}, },
) )
assert created["name"] == "tank" assert isinstance(created, str) and created.startswith("UPID:")
ops = pool.node_metadata["pve01"]["ops"] ops = pool.node_metadata["pve01"]["ops"]
assert any(item["name"] == "tank" for item in ops["disks"]["directory"]) assert any(item["name"] == "tank" for item in ops["disks"]["directory"])
+13 -2
View File
@@ -37,7 +37,13 @@ class LxcPool:
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]: async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args del args
if "FROM resources" in sql and "kind='lxc'" in sql: if "FROM resources" in sql and "kind='lxc'" in sql:
return [{"vmid": 200, "state": '{"status":"stopped","name":"service"}'}] return [
{
"vmid": 200,
"state": '{"status":"stopped","name":"service"}',
"config": '{"hostname":"service","memory":512,"cores":1}',
}
]
assert "FROM snapshots" in sql assert "FROM snapshots" in sql
return [ return [
{ {
@@ -118,7 +124,12 @@ async def test_lxc_list_returns_seeded_containers(registry: HandlerRegistry) ->
handler = registry.get("/nodes/{node}/lxc", "GET") handler = registry.get("/nodes/{node}/lxc", "GET")
assert handler is not None assert handler is not None
result = await handler(_request(pool), {"values": {"node": "pve1"}}) result = await handler(_request(pool), {"values": {"node": "pve1"}})
assert result == [{"vmid": 200, "status": "stopped", "name": "service"}] assert len(result) == 1
assert result[0]["vmid"] == 200
assert result[0]["status"] == "stopped"
assert result[0]["name"] == "service"
assert result[0]["cpus"] == 1
assert "maxmem" in result[0]
async def test_lxc_create_rejects_duplicate_vmid(registry: HandlerRegistry) -> None: async def test_lxc_create_rejects_duplicate_vmid(registry: HandlerRegistry) -> None:
+44 -6
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json import json
from typing import Any, cast from typing import Any, cast
import pytest
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry from app.api.registry import HandlerRegistry
@@ -17,13 +18,17 @@ class NodePool:
self.metadata: dict[str, Any] = {} self.metadata: dict[str, Any] = {}
async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None: async def fetchrow(self, query: str, *arguments: object) -> dict[str, Any] | None:
del arguments
if "SELECT metadata FROM nodes" in query: if "SELECT metadata FROM nodes" in query:
return {"metadata": json.dumps(self.metadata)} return {"metadata": json.dumps(self.metadata)}
raise AssertionError(query) raise AssertionError(query)
async def fetchval(self, query: str, *arguments: object) -> Any: async def fetchval(self, query: str, *arguments: object) -> Any:
del arguments
if "EXISTS(SELECT 1 FROM nodes" in query: if "EXISTS(SELECT 1 FROM nodes" in query:
return True return True
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in query:
return "pve01"
raise AssertionError(query) raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str: async def execute(self, query: str, *arguments: object) -> str:
@@ -33,6 +38,16 @@ class NodePool:
raise AssertionError(query) raise AssertionError(query)
class FakeTaskRepository:
def __init__(self, pool: NodePool) -> None:
self.pool = pool
self.created: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> Any:
self.created.append(kwargs)
return type("Task", (), {"upid": kwargs["upid"]})()
class FakeDatabase: class FakeDatabase:
def __init__(self, pool: NodePool) -> None: def __init__(self, pool: NodePool) -> None:
self.pool = pool self.pool = pool
@@ -58,17 +73,29 @@ def request(pool: NodePool, *, method: str = "GET", path: str = "/") -> Request:
return result return result
async def test_network_and_service_mutations_persist() -> None: @pytest.fixture
def task_repo(monkeypatch: pytest.MonkeyPatch) -> FakeTaskRepository:
repository = FakeTaskRepository(NodePool())
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: repository)
return repository
async def test_network_and_service_mutations_persist(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = HandlerRegistry() registry = HandlerRegistry()
register_node_ops_handlers(registry) register_node_ops_handlers(registry)
pool = NodePool() pool = NodePool()
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: repository)
create = registry.get("/nodes/{node}/network", "POST") create = registry.get("/nodes/{node}/network", "POST")
listing = registry.get("/nodes/{node}/network", "GET") listing = registry.get("/nodes/{node}/network", "GET")
delete = registry.get("/nodes/{node}/network/{iface}", "DELETE") delete = registry.get("/nodes/{node}/network/{iface}", "DELETE")
reload_network = registry.get("/nodes/{node}/network", "PUT")
stop = registry.get("/nodes/{node}/services/{service}/stop", "POST") stop = registry.get("/nodes/{node}/services/{service}/stop", "POST")
state = registry.get("/nodes/{node}/services/{service}/state", "GET") state = registry.get("/nodes/{node}/services/{service}/state", "GET")
assert create and listing and delete and stop and state assert create and listing and delete and reload_network and stop and state
await create( await create(
request(pool, method="POST", path="/api2/json/nodes/pve01/network"), request(pool, method="POST", path="/api2/json/nodes/pve01/network"),
@@ -90,10 +117,17 @@ async def test_network_and_service_mutations_persist() -> None:
) )
assert all(item["iface"] != "vmbr9" for item in items) assert all(item["iface"] != "vmbr9" for item in items)
await stop( reload_upid = await reload_network(
request(pool, method="PUT", path="/api2/json/nodes/pve01/network"),
{"values": {"node": "pve01"}, "provided": frozenset()},
)
assert isinstance(reload_upid, str) and reload_upid.startswith("UPID:")
stop_upid = await stop(
request(pool, method="POST", path="/api2/json/nodes/pve01/services/pveproxy/stop"), request(pool, method="POST", path="/api2/json/nodes/pve01/services/pveproxy/stop"),
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()}, {"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
) )
assert isinstance(stop_upid, str) and stop_upid.startswith("UPID:")
service = await state( service = await state(
request(pool), request(pool),
{"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()}, {"values": {"node": "pve01", "service": "pveproxy"}, "provided": frozenset()},
@@ -102,23 +136,27 @@ async def test_network_and_service_mutations_persist() -> None:
assert "ops" in pool.metadata assert "ops" in pool.metadata
async def test_disk_init_and_wipe_persist() -> None: async def test_disk_init_and_wipe_persist(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry() registry = HandlerRegistry()
register_node_ops_handlers(registry) register_node_ops_handlers(registry)
pool = NodePool() pool = NodePool()
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.nodes.TaskRepository", lambda _pool: repository)
initgpt = registry.get("/nodes/{node}/disks/initgpt", "POST") initgpt = registry.get("/nodes/{node}/disks/initgpt", "POST")
wipe = registry.get("/nodes/{node}/disks/wipedisk", "PUT") wipe = registry.get("/nodes/{node}/disks/wipedisk", "PUT")
listing = registry.get("/nodes/{node}/disks/list", "GET") listing = registry.get("/nodes/{node}/disks/list", "GET")
assert initgpt and wipe and listing assert initgpt and wipe and listing
await initgpt( init_upid = await initgpt(
request(pool, method="POST"), request(pool, method="POST"),
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()}, {"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
) )
await wipe( wipe_upid = await wipe(
request(pool, method="PUT"), request(pool, method="PUT"),
{"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()}, {"values": {"node": "pve01", "disk": "/dev/sdb"}, "provided": frozenset()},
) )
assert init_upid.startswith("UPID:")
assert wipe_upid.startswith("UPID:")
disks = await listing( disks = await listing(
request(pool), request(pool),
{"values": {"node": "pve01"}, "provided": frozenset()}, {"values": {"node": "pve01"}, "provided": frozenset()},
+11 -3
View File
@@ -41,7 +41,13 @@ class QemuPool:
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]: async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args del args
if "FROM resources" in sql: if "FROM resources" in sql:
return [{"vmid": 150, "state": '{"status":"stopped","name":"vm"}'}] return [
{
"vmid": 150,
"state": '{"status":"stopped","name":"vm"}',
"config": '{"name":"vm","memory":2048,"cores":2}',
}
]
assert "FROM snapshots" in sql assert "FROM snapshots" in sql
return [ return [
{ {
@@ -325,9 +331,11 @@ async def test_qemu_clone_and_migrate_handlers(monkeypatch: pytest.MonkeyPatch)
) )
assert migrate_upid.startswith("UPID:pve1:") assert migrate_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-migrate" assert tasks[-1]["task_type"] == "qemu-migrate"
assert ( resize_upid = await resize(
await resize(http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")) is None http_request, inputs(node="pve1", vmid=150, disk="scsi0", size="+2G")
) )
assert resize_upid.startswith("UPID:pve1:")
assert tasks[-1]["task_type"] == "qemu-resize"
move_upid = await move( move_upid = await move(
http_request, inputs(node="pve1", vmid=150, disk="scsi0", storage="local") http_request, inputs(node="pve1", vmid=150, disk="scsi0", storage="local")
) )
+58 -1
View File
@@ -1,6 +1,11 @@
"""Tests for contract example generation.""" """Tests for contract example generation."""
from app.contracts.examples import path_param_example, schema_example from app.contracts.examples import (
path_param_example,
property_string_example,
schema_example,
wire_param_name,
)
from app.contracts.model import Schema from app.contracts.model import Schema
@@ -9,6 +14,12 @@ def test_path_param_examples_use_known_placeholders() -> None:
assert path_param_example("vmid") == 100 assert path_param_example("vmid") == 100
def test_wire_param_name_expands_indexed() -> None:
assert wire_param_name("link[n]") == "link0"
assert wire_param_name("scsi[n]", index=1) == "scsi1"
assert wire_param_name("vmid") == "vmid"
def test_schema_example_prefers_default_and_enum() -> None: def test_schema_example_prefers_default_and_enum() -> None:
assert schema_example(Schema(type="string", default="custom")) == "custom" assert schema_example(Schema(type="string", default="custom")) == "custom"
assert schema_example(Schema(type="string", enum=("a", "b"))) == "a" assert schema_example(Schema(type="string", enum=("a", "b"))) == "a"
@@ -23,3 +34,49 @@ def test_schema_example_builds_object_and_array() -> None:
}, },
) )
assert schema_example(schema) == {"count": 2} assert schema_example(schema) == {"count": 2}
def test_schema_example_uses_named_proxmox_formats() -> None:
assert schema_example(Schema(type="string", format="pve-node"), name="node") == "pve01"
assert schema_example(Schema(type="string", format="pve-storage-id")) == "local"
assert schema_example(Schema(type="string", format="ip")) == "192.168.0.1"
# Parameter name hints win over the format token when both apply.
assert schema_example(Schema(type="string", format="pve-node"), name="clustername") == "example"
def test_property_string_example_uses_bare_default_key() -> None:
fmt = {
"address": {
"default_key": 1,
"format": "address",
"type": "string",
},
"priority": {
"default": 0,
"optional": 1,
"type": "integer",
},
}
assert property_string_example(fmt) == "192.168.0.1"
assert schema_example(Schema(type="string", format=fmt), name="link[n]") == "192.168.0.1"
def test_property_string_example_includes_required_keys() -> None:
fmt = {
"enable": {
"default_key": 1,
"default": "1",
"type": "boolean",
},
"burst": {
"default": 5,
"type": "integer",
"minimum": 0,
},
"rate": {
"default": "1/second",
"optional": 1,
"type": "string",
},
}
assert property_string_example(fmt) == "1,burst=5"
+34 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json import json
from typing import Any, cast from typing import Any, cast
import pytest
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from app.api.registry import HandlerRegistry from app.api.registry import HandlerRegistry
@@ -17,6 +18,11 @@ class SdnPool:
self.metadata: dict[str, Any] = {} self.metadata: dict[str, Any] = {}
self.nodes = {"pve1"} self.nodes = {"pve1"}
async def fetch(self, query: str, *_arguments: object) -> list[dict[str, Any]]:
if "FROM nodes" in query:
return [{"name": name} for name in sorted(self.nodes)]
raise AssertionError(query)
async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None: async def fetchrow(self, query: str, *_arguments: object) -> dict[str, Any] | None:
if "FROM clusters WHERE id" in query: if "FROM clusters WHERE id" in query:
return {"metadata": json.dumps(self.metadata)} return {"metadata": json.dumps(self.metadata)}
@@ -25,6 +31,8 @@ class SdnPool:
async def fetchval(self, query: str, *arguments: object) -> Any: async def fetchval(self, query: str, *arguments: object) -> Any:
if "EXISTS(SELECT 1 FROM nodes" in query: if "EXISTS(SELECT 1 FROM nodes" in query:
return str(arguments[0]) in self.nodes return str(arguments[0]) in self.nodes
if "SELECT name FROM nodes ORDER BY name LIMIT 1" in query:
return sorted(self.nodes)[0]
raise AssertionError(query) raise AssertionError(query)
async def execute(self, query: str, *arguments: object) -> str: async def execute(self, query: str, *arguments: object) -> str:
@@ -34,6 +42,16 @@ class SdnPool:
raise AssertionError(query) raise AssertionError(query)
class FakeTaskRepository:
def __init__(self, pool: SdnPool) -> None:
self.pool = pool
self.created: list[dict[str, Any]] = []
async def create(self, **kwargs: Any) -> Any:
self.created.append(kwargs)
return type("Task", (), {"upid": kwargs["upid"]})()
async def call( async def call(
registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any] registry: HandlerRegistry, path: str, verb: str, http: Request, inputs: dict[str, Any]
) -> Any: ) -> Any:
@@ -45,7 +63,7 @@ async def call(
def request(pool: SdnPool) -> Request: def request(pool: SdnPool) -> Request:
app = FastAPI() app = FastAPI()
app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})()) app.state.database = cast(AsyncpgDatabase, type("DB", (), {"pool": pool})())
return Request( http = Request(
{ {
"type": "http", "type": "http",
"app": app, "app": app,
@@ -58,13 +76,17 @@ def request(pool: SdnPool) -> Request:
"scheme": "http", "scheme": "http",
} }
) )
http.state.principal = "root@pam"
return http
async def test_sdn_zone_vnet_subnet_and_node_views() -> None: async def test_sdn_zone_vnet_subnet_and_node_views(monkeypatch: pytest.MonkeyPatch) -> None:
registry = HandlerRegistry() registry = HandlerRegistry()
register_sdn_handlers(registry) register_sdn_handlers(registry)
pool = SdnPool() pool = SdnPool()
http = request(pool) http = request(pool)
repository = FakeTaskRepository(pool)
monkeypatch.setattr("app.handlers.cluster_extra.TaskRepository", lambda _pool: repository)
await call( await call(
registry, registry,
@@ -118,11 +140,20 @@ async def test_sdn_zone_vnet_subnet_and_node_views() -> None:
) )
assert node_zones[0]["zone"] == "localzone" assert node_zones[0]["zone"] == "localzone"
assert pool.metadata["sdn"]["pending"] is True assert pool.metadata["sdn"]["pending"] is True
await call( upid = await call(
registry, registry,
"/cluster/sdn", "/cluster/sdn",
"PUT", "PUT",
http, http,
{"values": {"release-lock": 1}, "provided": frozenset()}, {"values": {"release-lock": 1}, "provided": frozenset()},
) )
assert isinstance(upid, str) and upid.startswith("UPID:")
assert pool.metadata["sdn"]["pending"] is False assert pool.metadata["sdn"]["pending"] is False
lock_token = await call(
registry,
"/cluster/sdn/lock",
"POST",
http,
{"values": {}, "provided": frozenset()},
)
assert isinstance(lock_token, str) and len(lock_token) >= 8
+105 -10
View File
@@ -5,21 +5,23 @@ from typing import Any, cast
import pytest import pytest
from app.simulation.seed import ( from app.simulation.seed import (
big_profile,
build_profile, build_profile,
clear_simulation_state, clear_simulation_state,
cluster_domain_metadata, cluster_domain_metadata,
default_node_ops_for_seed, default_node_ops_for_seed,
enrich_guest_state, enrich_guest_state,
enrich_storage_state, enrich_storage_state,
lab_profile,
large_profile, large_profile,
small_profile, small_profile,
stable_id, stable_id,
) )
def test_small_profile_matches_required_logical_shape() -> None: def test_lab_profile_matches_required_logical_shape() -> None:
first = small_profile() first = lab_profile()
second = small_profile() second = lab_profile()
assert first == second assert first == second
state = first.logical_state() state = first.logical_state()
@@ -29,17 +31,54 @@ def test_small_profile_matches_required_logical_shape() -> None:
assert isinstance(resources, list) assert isinstance(resources, list)
assert [resource["kind"] for resource in resources].count("qemu") == 2 assert [resource["kind"] for resource in resources].count("qemu") == 2
assert [resource["kind"] for resource in resources].count("lxc") == 1 assert [resource["kind"] for resource in resources].count("lxc") == 1
assert [resource["kind"] for resource in resources].count("storage") == 2 assert [resource["kind"] for resource in resources].count("storage") == 3
assert [resource["kind"] for resource in resources].count("ceph-osd") == 3
assert [resource["kind"] for resource in resources].count("ha") == 1
tasks = state["tasks"] tasks = state["tasks"]
assert isinstance(tasks, list) assert isinstance(tasks, list)
assert len(tasks) == 2 assert len(tasks) == 2
def test_sized_cluster_profiles() -> None:
small = small_profile()
assert len(small.nodes) == 3
assert sum(resource.kind in {"qemu", "lxc"} for resource in small.resources) == 50
assert sum(resource.kind == "ceph-osd" for resource in small.resources) == 10
assert sum(resource.kind == "ha" for resource in small.resources) == 3
assert len(small.tasks) == 12
large = large_profile()
assert len(large.nodes) == 10
assert sum(resource.kind in {"qemu", "lxc"} for resource in large.resources) == 1_000
assert sum(resource.kind == "ceph-osd" for resource in large.resources) == 100
assert sum(resource.kind == "pool" for resource in large.resources) == 2
assert len(large.tasks) == 50
big = big_profile()
assert len(big.nodes) == 20
assert sum(resource.kind in {"qemu", "lxc"} for resource in big.resources) == 2_000
assert sum(resource.kind == "ceph-osd" for resource in big.resources) == 500
assert sum(resource.kind == "pool" for resource in big.resources) == 3
assert len(big.tasks) == 100
assert build_profile("big") == big
def test_sized_profiles_spread_osds_across_nodes() -> None:
for profile_name, expected in (("small", 10), ("large", 100), ("big", 500)):
profile = build_profile(profile_name)
names = {node.id: node.name for node in profile.nodes}
counts: dict[str, int] = {name: 0 for name in names.values()}
for resource in profile.resources:
if resource.kind == "ceph-osd":
counts[names[resource.node_id]] += 1
assert sum(counts.values()) == expected
assert max(counts.values()) - min(counts.values()) <= 1
def test_medium_and_fault_profiles_are_deterministic() -> None: def test_medium_and_fault_profiles_are_deterministic() -> None:
medium = build_profile("medium") medium = build_profile("medium")
assert len(medium.nodes) == 3 assert len(medium.nodes) == 3
assert sum(resource.kind == "qemu" for resource in medium.resources) == 50 assert sum(resource.kind in {"qemu", "lxc"} for resource in medium.resources) == 50
assert sum(resource.kind == "lxc" for resource in medium.resources) == 20
assert build_profile("ha-demo") == build_profile("ha-demo") assert build_profile("ha-demo") == build_profile("ha-demo")
broken = build_profile("broken-storage") broken = build_profile("broken-storage")
assert any(resource.state.get("status") == "offline" for resource in broken.resources) assert any(resource.state.get("status") == "offline" for resource in broken.resources)
@@ -50,7 +89,10 @@ def test_large_profile_is_configurable_and_stable() -> None:
second = large_profile(node_count=4, resource_count=1_000) second = large_profile(node_count=4, resource_count=1_000)
assert first == second assert first == second
assert len(first.nodes) == 4 assert len(first.nodes) == 4
assert len(first.resources) == 1_000 guests = [r for r in first.resources if r.kind in {"qemu", "lxc"}]
assert len(guests) == 1_000
assert any(r.kind == "storage" and r.external_id == "local" for r in first.resources)
assert any(r.kind == "ceph-osd" for r in first.resources)
def test_profile_validation() -> None: def test_profile_validation() -> None:
@@ -108,7 +150,7 @@ def test_stable_ids_are_namespaced_and_repeatable() -> None:
def test_cluster_domain_metadata_seeds_list_domains() -> None: def test_cluster_domain_metadata_seeds_list_domains() -> None:
meta = cast(dict[str, Any], cluster_domain_metadata(small_profile())) meta = cast(dict[str, Any], cluster_domain_metadata(lab_profile()))
assert meta["firewall"]["scopes"]["cluster"]["rules"] assert meta["firewall"]["scopes"]["cluster"]["rules"]
assert meta["firewall"]["scopes"]["cluster"]["aliases"] assert meta["firewall"]["scopes"]["cluster"]["aliases"]
assert meta["firewall"]["scopes"]["cluster"]["ipset"] assert meta["firewall"]["scopes"]["cluster"]["ipset"]
@@ -127,23 +169,36 @@ def test_cluster_domain_metadata_seeds_list_domains() -> None:
assert meta["notifications"]["matchers"] assert meta["notifications"]["matchers"]
assert meta["notifications"]["matcher_fields"] assert meta["notifications"]["matcher_fields"]
assert meta["acme"]["accounts"] assert meta["acme"]["accounts"]
assert meta["acme"]["accounts"]["letsencrypt"]["tos_url"]
assert meta["acme"]["plugins"] assert meta["acme"]["plugins"]
assert meta["acme"]["directories"] assert meta["acme"]["directories"]
assert meta["acme"]["challenge_schema"] assert meta["acme"]["challenge_schema"]
assert meta["mapping"]["pci"] assert meta["mapping"]["pci"]
assert meta["mapping"]["usb"] assert meta["mapping"]["usb"]
assert meta["mapping"]["dir"] assert meta["mapping"]["dir"]
assert meta["replication"] # Single-node lab must not invent ghost peers.
assert meta["replication"] == []
assert "pve02" not in str(meta["sdn"]["controllers"])
assert meta["metrics"]["servers"] assert meta["metrics"]["servers"]
assert meta["ha_groups"] assert meta["ha_groups"]
assert meta["ceph"]["pools"] assert meta["ceph"]["pools"]
assert meta["ceph"]["flags"] assert meta["ceph"]["flags"]
assert "noup" in meta["ceph"]["flags"]
assert meta["ceph"]["flags"]["nobackfill"] == 0
assert meta["ceph"]["health"]["status"] == "HEALTH_OK"
assert meta["ceph"]["version"] assert meta["ceph"]["version"]
assert len([r for r in lab_profile().resources if r.kind == "ceph-osd"]) == 3
assert any(r.kind == "storage" and r.external_id == "ceph" for r in lab_profile().resources)
local = next(r for r in lab_profile().resources if r.external_id == "local")
assert int(cast(int, local.state["total_bytes"])) > 0
assert lab_profile().tasks[0].payload["node"] == "pve01"
assert meta["qemu_cpu_flags"] assert meta["qemu_cpu_flags"]
assert meta["metrics"]["export_data"] assert meta["metrics"]["export_data"]
assert meta["jobs"]["schedule_analyze_results"] assert meta["jobs"]["schedule_analyze_results"]
assert meta["replication"][0]["log"]
ops = cast(dict[str, Any], default_node_ops_for_seed("pve01")) ops = cast(dict[str, Any], default_node_ops_for_seed("pve01"))
assert ops["vzdump"]["defaults"]["storage"] == "local"
assert ops["status"]["mem"] > 0
assert ops["status"]["maxmem"] > 0
assert ops["capabilities"]["cpu"] assert ops["capabilities"]["cpu"]
assert ops["capabilities"]["machines"] assert ops["capabilities"]["machines"]
assert ops["hosts"]["data"] assert ops["hosts"]["data"]
@@ -154,11 +209,21 @@ def test_cluster_domain_metadata_seeds_list_domains() -> None:
assert ops["status"]["uptime"] assert ops["status"]["uptime"]
assert ops["ip"] assert ops["ip"]
assert meta["cluster_config"]["totem"] assert meta["cluster_config"]["totem"]
assert meta["cluster_config"]["config_digest"]
assert meta["cluster_config"]["corosync_conf"]
assert meta["cluster_config"]["corosync_authkey"]
first_node = next(iter(meta["cluster_config"]["added_nodes"].values()))
assert first_node["pve_fp"].count(":") == 31
assert first_node["pve_addr"]
assert meta["backup_jobs"]["backup-daily"]["storage"] == "local"
assert meta["quorate"] == 1 assert meta["quorate"] == 1
guest = cast( guest = cast(
dict[str, Any], dict[str, Any],
enrich_guest_state({"name": "demo", "status": "stopped"}, kind="qemu", vmid="100"), enrich_guest_state({"name": "demo", "status": "stopped"}, kind="qemu", vmid="100"),
) )
assert guest["scsi0"].startswith("local-lvm:")
assert guest["ostype"] == "l26"
assert str(guest["net0"]).startswith("virtio=")
assert guest["agent"]["results"]["info"] assert guest["agent"]["results"]["info"]
assert guest["rrddata"] assert guest["rrddata"]
assert guest["migrate_preconditions"] assert guest["migrate_preconditions"]
@@ -170,6 +235,36 @@ def test_cluster_domain_metadata_seeds_list_domains() -> None:
assert storage["file_restore"] assert storage["file_restore"]
assert storage["import_metadata"] assert storage["import_metadata"]
small_meta = cast(dict[str, Any], cluster_domain_metadata(small_profile()))
assert len(small_meta["replication"]) == 2
assert small_meta["replication"][0]["guest"] == 101
assert len(small_meta["backup_jobs"]) == 2
assert len(small_meta["ha_groups"]) == 2
assert small_meta["sdn"]["zones"]["public"]["digest"]
assert small_meta["sdn"]["zones"]["public"]["state"] == "available"
assert len(cast(dict[str, Any], cluster_domain_metadata(large_profile()))["replication"]) == 8
assert len(cast(dict[str, Any], cluster_domain_metadata(large_profile()))["ha_groups"]) == 4
assert len(cast(dict[str, Any], cluster_domain_metadata(big_profile()))["replication"]) == 16
assert len(cast(dict[str, Any], cluster_domain_metadata(big_profile()))["ha_groups"]) == 6
assert len(cast(dict[str, Any], cluster_domain_metadata(big_profile()))["ceph"]["pools"]) == 3
ops = cast(dict[str, Any], default_node_ops_for_seed("pve1", node_index=1, node_count=3))
apt_repos = cast(dict[str, Any], cast(dict[str, Any], ops["apt"])["repositories"])
assert "files" in apt_repos and "standard-repos" in apt_repos
cert = cast(list[dict[str, Any]], cast(dict[str, Any], ops["certificates"])["info"])[0]
assert cert["pem"] and cert["san"] and cert["public-key-bits"] == 4096
def test_sized_profile_artifact_targets() -> None:
from app.simulation.seed import _CLUSTER_SIZE_SPECS
assert _CLUSTER_SIZE_SPECS["small"]["backups"] == 10
assert _CLUSTER_SIZE_SPECS["small"]["snapshots"] == 12
assert _CLUSTER_SIZE_SPECS["large"]["backups"] == 80
assert _CLUSTER_SIZE_SPECS["large"]["snapshots"] == 100
assert _CLUSTER_SIZE_SPECS["big"]["backups"] == 160
assert _CLUSTER_SIZE_SPECS["big"]["snapshots"] == 200
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_clear_simulation_state_wipes_api_created_identity() -> None: async def test_clear_simulation_state_wipes_api_created_identity() -> None: