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
+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 collections.abc import Mapping
from typing import Any
from app.contracts.model import Schema
_PATH_PARAM_EXAMPLES: dict[str, object] = {
@@ -22,6 +30,53 @@ _PATH_PARAM_EXAMPLES: dict[str, object] = {
"key": "cpu",
"digest": "00000000",
"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)
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:
"""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"))
if hinted is not None:
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.items is not None:
return [schema_example(schema.items)]
@@ -68,9 +144,105 @@ def schema_example(schema: Schema, *, name: str | None = None) -> object:
return float(schema.minimum)
return 1.0
if schema.type == "string" or schema.type is None:
if schema.format == "email":
return "user@example.com"
if schema.format == "uri":
return "https://example.com"
return "example"
return None
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
+85 -14
View File
@@ -125,7 +125,7 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
return [
{
"userid": str(row["name"]),
"enable": 1 if row["enabled"] else 0,
"enable": bool(row["enabled"]),
"realm-type": str(row["realm_kind"]),
}
for row in rows
@@ -168,10 +168,25 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
)
if row is None:
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 {
"userid": str(row["name"]),
"enable": 1 if row["enabled"] else 0,
"enable": bool(row["enabled"]),
"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:
@@ -397,10 +412,18 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
path = str(payload["path"])
roleid = str(payload["roles"])
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()]
groups = [
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:
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
@@ -408,10 +431,15 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
)
if principal_id is None:
raise ApiError(404, f"user {userid} does not exist")
await database(request).pool.execute(
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""",
roleid,
)
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,
path,
)
continue
await database(request).pool.execute(
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate)
VALUES($1, $2, $3, $4)
@@ -429,10 +457,15 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
)
if group_id is None:
raise ApiError(404, f"group {groupid} does not exist")
await database(request).pool.execute(
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""",
roleid,
)
if delete:
await database(request).pool.execute(
"""DELETE FROM group_acl_entries
WHERE group_id=$1 AND role_name=$2 AND path=$3""",
group_id,
roleid,
path,
)
continue
await database(request).pool.execute(
"""INSERT INTO group_acl_entries(group_id, role_name, path, propagate)
VALUES($1, $2, $3, $4)
@@ -443,6 +476,46 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
path,
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]]:
userid = str(values(inputs)["userid"])
@@ -562,10 +635,8 @@ def register_access_handlers(registry: HandlerRegistry) -> None:
)
if row is None:
raise ApiError(404, "role does not exist")
return {
"roleid": str(row["name"]),
"privs": ",".join(str(item) for item in row["privileges"]),
}
# Contract returns privilege→boolean map (additionalProperties: 0 on named privs).
return {str(priv): 1 for priv in row["privileges"]}
async def role_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
+16 -5
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import hashlib
import json
from typing import Any
from fastapi import Request
@@ -35,7 +37,7 @@ def _default_directory(acme: dict[str, Any]) -> str:
for item in directories:
if isinstance(item, dict) and item.get("url"):
return str(item["url"])
return ""
return "https://acme-v02.api.letsencrypt.org/directory"
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())
]
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)
name = str(payload.get("name") or "default")
metadata = await cluster_metadata(request)
@@ -80,6 +82,7 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
"location": f"https://acme.example.local/acct/{name}",
}
await save_cluster_metadata(request, metadata)
return name
async def account_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["name"])
@@ -88,8 +91,10 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
if not isinstance(account, dict):
raise ApiError(404, "ACME account does not exist")
return {
"name": name,
"contact": account.get("contact"),
"account": {
"name": name,
"contact": account.get("contact"),
},
"directory": account.get("directory"),
"tos": account.get("tos_url"),
"location": account.get("location"),
@@ -123,7 +128,13 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
for plugin_id, item in sorted(plugins.items()):
if plugin_type and item.get("type") != plugin_type:
continue
result.append({"plugin": plugin_id, **{k: v for k, v in item.items() if k != "data"}})
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
async def plugins_create(request: Request, inputs: dict[str, Any]) -> None:
+182 -132
View File
@@ -1,8 +1,8 @@
"""Cluster backup and vzdump handlers."""
"""Cluster backup jobs and node vzdump handlers."""
from __future__ import annotations
import json
import secrets
from typing import Any
from fastapi import Request
@@ -10,149 +10,198 @@ from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.primitives import ConflictError
from app.handlers.common import database, require_node, state, values
from app.handlers.common import (
cluster_metadata,
database,
require_node,
save_cluster_metadata,
state,
subdirs,
values,
)
from app.tasks.repository import TaskRepository
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:
async def backup_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
r.external_id AS vmid, n.name AS node, s.storage_id
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
LEFT JOIN nodes n ON n.id = r.node_id
JOIN storages s ON s.resource_id = b.storage_resource_id
ORDER BY b.created_at DESC LIMIT 2000"""
async def backup_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
return [_job_view(job_id, payload) for job_id, payload in sorted(jobs.items())]
async def backup_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
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]] = []
if "*" in covered:
return result
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(
{
"id": str(row["id"]),
"volid": str(row["volume_id"]),
"size": int(row["size_bytes"]),
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
"node": str(row["node"]) if row["node"] is not None else None,
"storage": str(row["storage_id"]),
"starttime": int(row["created_at"].timestamp()),
"mode": metadata.get("mode", "snapshot"),
"type": metadata.get("type", "vzdump"),
"vmid": int(vmid),
"type": "qemu" if row["kind"] == "qemu" else "lxc",
"name": str(guest_state.get("name") or f"{row['kind']}-{vmid}"),
}
)
return result
async def backup_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
backup_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow(
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
r.external_id AS vmid, n.name AS node, s.storage_id
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
LEFT JOIN nodes n ON n.id = r.node_id
JOIN storages s ON s.resource_id = b.storage_resource_id
WHERE b.id::text = $1 OR b.volume_id = $1""",
backup_id,
)
if row is None:
raise ApiError(404, "backup does not exist")
metadata = state(row["metadata"])
return {
"id": str(row["id"]),
"volid": str(row["volume_id"]),
"size": int(row["size_bytes"]),
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
"node": str(row["node"]) if row["node"] is not None else None,
"storage": str(row["storage_id"]),
"starttime": int(row["created_at"].timestamp()),
"notes": metadata.get("notes-template"),
**metadata,
}
async def backup_update(request: Request, inputs: dict[str, Any]) -> None:
backup_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow(
"SELECT id, metadata FROM backups WHERE id::text = $1",
backup_id,
)
if row is None:
raise ApiError(404, "backup does not exist")
metadata = state(row["metadata"])
payload = values(inputs)
if "notes" in payload:
metadata["notes-template"] = payload["notes"]
await database(request).pool.execute(
"UPDATE backups SET metadata=$2::jsonb WHERE id=$1",
row["id"],
json.dumps(metadata, sort_keys=True),
)
async def backup_delete(request: Request, inputs: dict[str, Any]) -> None:
backup_id = str(values(inputs)["id"])
status = await database(request).pool.execute(
"DELETE FROM backups WHERE id::text = $1",
backup_id,
)
if status != "DELETE 1":
raise ApiError(404, "backup does not exist")
async def backup_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload.get("node") or payload.get("target") or "pve01")
await require_node(request, node)
vmid = payload.get("vmid")
return await _schedule_vzdump(
request,
node=node,
vmids=[str(vmid)] if vmid is not None else None,
payload=payload,
)
async def backup_info(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT r.external_id AS vmid, n.name AS node, max(b.created_at) AS last_backup
async def backup_included_volumes(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _backup_jobs(metadata)
job = jobs.get(job_id)
if job is None:
raise ApiError(404, "backup job does not exist")
vmids = [item.strip() for item in str(job.get("vmid") or "").split(",") if item.strip()]
guest_select = """SELECT r.external_id, r.kind, r.state,
v.config AS qemu_config, c.config AS lxc_config
FROM resources r
JOIN nodes n ON n.id = r.node_id
LEFT JOIN backups b ON b.resource_id = r.id
WHERE r.kind = 'qemu'
GROUP BY r.external_id, n.name
ORDER BY r.external_id::integer
LIMIT 5000"""
)
return [
{
"vmid": int(row["vmid"]),
"node": str(row["node"]),
"lastbackup": int(row["last_backup"].timestamp()) if row["last_backup"] else 0,
"protected": 0,
}
for row in rows
]
async def backup_not_backed_up(_request: Request, _inputs: dict[str, Any]) -> list[int]:
rows = await database(_request).pool.fetch(
"""SELECT r.external_id::integer AS vmid
FROM resources r
LEFT JOIN backups b ON b.resource_id = r.id
WHERE r.kind = 'qemu' AND b.id IS NULL
ORDER BY r.external_id::integer"""
)
return [int(row["vmid"]) for row in rows]
async def backup_included_volumes(request: Request, inputs: dict[str, Any]) -> list[str]:
backup_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow(
"""SELECT b.volume_id, r.external_id AS vmid
FROM backups b LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.id::text = $1""",
backup_id,
)
if row is None:
raise ApiError(404, "backup does not exist")
vmid = row["vmid"]
return [f"qemu/{vmid}"] if vmid is not None else [str(row["volume_id"])]
LEFT JOIN virtual_machines v ON v.resource_id = r.id
LEFT JOIN containers c ON c.resource_id = r.id"""
if int(job.get("all") or 0):
rows = await database(request).pool.fetch(
f"""{guest_select}
WHERE r.kind IN ('qemu', 'lxc')
ORDER BY r.external_id::integer"""
)
else:
rows = await database(request).pool.fetch(
f"""{guest_select}
WHERE r.kind IN ('qemu', 'lxc') AND r.external_id = ANY($1::text[])
ORDER BY r.external_id::integer""",
vmids,
)
children: list[dict[str, Any]] = []
for row in rows:
guest_state = state(row["state"])
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]:
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", "POST", backup_create)
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/{id}", "GET", backup_get)
registry.register("/cluster/backup/{id}", "PUT", backup_update)
@@ -229,11 +279,11 @@ async def _schedule_vzdump(
payload={
"node": node,
"vmids": vmids,
"storage": str(payload.get("storage") or "nfs-backup"),
"storage": str(payload.get("storage") or "local"),
"mode": str(payload.get("mode") or "snapshot"),
"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"),
)
except ConflictError as error:
+106 -16
View File
@@ -242,11 +242,45 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
await _save_cluster_ceph(request, ceph)
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]]:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
pools = ceph.get("pools") or {}
return [dict(item) for _, item in sorted(pools.items()) if isinstance(item, dict)]
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:
payload = values(inputs)
@@ -278,7 +312,10 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
pool = (ceph.get("pools") or {}).get(name)
if not isinstance(pool, dict):
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:
payload = values(inputs)
@@ -482,30 +519,63 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephdestroymon")
async def osd_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
async def osd_list(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
rows = await database(request).pool.fetch(
"""SELECT r.external_id, r.state
"""SELECT r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='ceph-osd'
ORDER BY r.external_id""",
node,
WHERE r.kind='ceph-osd'
ORDER BY n.name, r.external_id"""
)
result: list[dict[str, Any]] = []
hosts: dict[str, dict[str, Any]] = {}
host_id = -2
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"])
osd_id = payload.get("osd_id", row["external_id"])
result.append(
osd_raw = payload.get("osd_id", row["external_id"])
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"),
"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"),
"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:
payload = values(inputs)
@@ -677,16 +747,36 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
health = ceph.get("health")
if not isinstance(health, dict):
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 {
"version": version_str,
"version": {
"version": version_str,
"release": "reef",
"epoch": 0,
},
"health": health,
"osdmap": {
"epoch": 1,
"num_osds": num_osds,
"num_up_osds": num_up,
"num_in_osds": num_in,
"num_remapped_pgs": 0,
},
"pgmap": {"bytes_used": used, "bytes_total": total},
"fsmap": {"filesystems": list((ceph.get("fs") or {}).keys())},
"pgmap": {
"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"
+65 -12
View File
@@ -24,7 +24,43 @@ def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
jobs = metadata.get("replication", [])
if not isinstance(jobs, list):
return []
return [dict(item) for item in jobs if isinstance(item, dict)]
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:
@@ -53,24 +89,41 @@ def register_cluster_handlers(registry: HandlerRegistry) -> None:
"""SELECT id, name, status FROM nodes ORDER BY name"""
)
metadata = await cluster_metadata(request)
quorate = metadata.get("quorate")
result: list[dict[str, Any]] = []
quorate = bool(int(metadata.get("quorate", 1) or 1))
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):
online = str(row["status"]) == "online"
ops = await load_node_ops(request, str(row["name"]))
cluster_node = ops.get("cluster_status")
entry = dict(cluster_node) if isinstance(cluster_node, dict) else {}
local_raw = entry.get("local", index == 0)
node_name = str(row["name"])
result.append(
{
"id": str(row["id"]),
"name": str(row["name"]),
"nodeid": entry.get("nodeid", index),
"online": 1 if online else 0,
"local": entry.get("local", 1 if index == 0 else 0),
"ip": entry.get("ip") or ops.get("ip") or "",
"level": entry.get("level", "c"),
"type": entry.get("type", "node"),
"quorate": entry.get("quorate", quorate if quorate is not None else 1),
"type": "node",
"id": f"node/{node_name}",
"name": node_name,
"nodeid": int(entry.get("nodeid", index + 1)),
"online": online,
"local": bool(int(local_raw)) if not isinstance(local_raw, bool) else local_raw,
"ip": str(entry.get("ip") or ops.get("ip") or ""),
"level": str(entry.get("level", "")),
}
)
return result
+253 -45
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
import secrets
from typing import Any
@@ -12,10 +13,12 @@ from app.api.registry import HandlerRegistry
from app.handlers.common import (
cluster_metadata,
database,
require_value,
save_cluster_metadata,
subdirs,
values,
)
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
def _config(metadata: dict[str, Any]) -> dict[str, Any]:
@@ -23,19 +26,132 @@ def _config(metadata: dict[str, Any]) -> dict[str, Any]:
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:
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)
clustername = str(require_value(payload, "clustername"))
metadata = await cluster_metadata(request)
config = dict(_config(metadata))
if payload.get("clustername"):
config["clustername"] = str(payload["clustername"])
totem = dict(config.get("totem") or {})
totem["cluster_name"] = str(payload["clustername"])
config["totem"] = totem
# Match PVE: refuse when corosync materials already exist (seeded or prior create).
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["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
if "votes" in payload:
config["votes"] = payload["votes"]
if "nodeid" in payload:
@@ -44,13 +160,43 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
if links:
config["links"] = links
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
await save_cluster_metadata(request, metadata)
await database(request).pool.execute(
"""UPDATE clusters
SET name=$1, updated_at=now()
WHERE id=(SELECT id FROM clusters LIMIT 1)""",
str(config["clustername"]),
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:
@@ -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]:
metadata = await cluster_metadata(request)
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")
nodelist = [
{"name": str(row["name"]), "online": 1 if row["status"] == "online" else 0}
for row in rows
]
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 {})
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 {
"clustername": config.get("clustername"),
"config_digest": secrets.token_hex(8),
"nodelist": nodelist,
"preferred_node": node or (nodelist[0]["name"] if nodelist else None),
"totem": config.get("totem", {}),
"links": config.get("links", {}),
"preferred_node": preferred_node,
"totem": dict(config.get("totem") or {}),
"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)
hostname = str(require_value(payload, "hostname"))
require_value(payload, "fingerprint")
require_value(payload, "password")
metadata = await cluster_metadata(request)
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[hostname] = {
"hostname": hostname,
@@ -89,23 +276,22 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
"nodeid": payload.get("nodeid"),
"votes": payload.get("votes", 1),
"force": payload.get("force"),
"password_set": True,
}
# password accepted but not stored in clear form
if payload.get("password"):
joins[hostname]["password_set"] = True
links = {key: value for key, value in payload.items() if key.startswith("link")}
if links:
joins[hostname]["links"] = links
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
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)
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]]:
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)
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 {})
result = []
for index, row in enumerate(rows, start=1):
name = str(row["name"])
@@ -122,33 +309,40 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
{
"node": name,
"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)),
}
)
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)
node = str(payload["node"])
node = str(require_value(payload, "node"))
metadata = await cluster_metadata(request)
config = dict(_config(metadata))
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,
"nodeid": payload.get("nodeid"),
"nodeid": payload.get("nodeid") or index,
"new_node_ip": payload.get("new_node_ip"),
"votes": payload.get("votes", 1),
"apiversion": payload.get("apiversion"),
"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)),
"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:
added[node]["links"] = links
entry["links"] = links
added[node] = entry
config["added_nodes"] = added
metadata["cluster_config"] = config
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
node,
@@ -159,7 +353,18 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
VALUES(gen_random_uuid(), $1, 'online', '{}'::jsonb)""",
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)
return {
"corosync_authkey": authkey,
"corosync_conf": conf,
"warnings": [],
}
async def nodes_delete(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
@@ -171,6 +376,9 @@ def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
joins.pop(node, None)
config["added_nodes"] = added
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
await save_cluster_metadata(request, metadata)
# Keep node row; mark offline to avoid cascading guest deletes.
+150 -27
View File
@@ -15,9 +15,11 @@ from app.handlers.common import (
database,
require_node,
save_cluster_metadata,
state,
subdirs,
values,
)
from app.simulation.ceph_defaults import CEPH_FLAG_DESCRIPTIONS, default_ceph_flags
from app.tasks.repository import TaskRepository
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]]:
jobs = metadata.get("replication", [])
if not isinstance(jobs, list):
return []
return [dict(item) for item in jobs if isinstance(item, dict)]
from app.handlers.cluster import _replication_jobs as normalize
return normalize(metadata)
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]:
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
return {
"data": str(metrics.get("export_data") or ""),
"timestamp": int(time.time()),
}
now = int(time.time())
seeded = metrics.get("export_series")
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(
request: Request, _inputs: dict[str, Any]
@@ -398,43 +431,69 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
) -> list[dict[str, str]]:
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)
ceph = _ceph(metadata)
flags = ceph.get("flags")
if not isinstance(flags, dict):
return {}
return {str(key): int(value) for key, value in flags.items()}
stored = ceph.get("flags")
flags = default_ceph_flags()
if isinstance(stored, dict):
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)
metadata = await cluster_metadata(request)
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():
if key in {"delete", "digest"}:
continue
if key not in CEPH_FLAG_DESCRIPTIONS:
raise ApiError(400, f"unknown ceph flag '{key}'")
flags[str(key)] = int(value)
ceph["flags"] = flags
metadata["ceph"] = ceph
await save_cluster_metadata(request, metadata)
return {str(key): int(value) for key, value in flags.items()}
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"])
if flag not in CEPH_FLAG_DESCRIPTIONS:
raise ApiError(404, "ceph flag does not exist")
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = ceph.get("flags")
if not isinstance(flags, dict) or flag not in flags:
raise ApiError(404, "ceph flag does not exist")
return {flag: int(flags[flag])}
if isinstance(flags, dict) and flag in flags:
return bool(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)
flag = str(payload["flag"])
if flag not in CEPH_FLAG_DESCRIPTIONS:
raise ApiError(404, "ceph flag does not exist")
metadata = await cluster_metadata(request)
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:
flags[flag] = int(payload["value"])
elif flag in payload:
@@ -444,7 +503,6 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
ceph["flags"] = flags
metadata["ceph"] = ceph
await save_cluster_metadata(request, metadata)
return {flag: int(flags[flag])}
async def ceph_metadata(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
@@ -452,12 +510,77 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
config_raw = ceph.get("config")
config: dict[str, Any] = dict(config_raw) if isinstance(config_raw, dict) else {}
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 {
"name": name,
"hostname": name,
"addr": f"10.0.0.{index}",
"ceph_version": version_str,
"ceph_version_short": version_short,
"ceph_release": "reef",
}
return {
"version": dict(version) if isinstance(version, dict) else {},
"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 ""),
"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:
+14 -3
View File
@@ -129,13 +129,24 @@ def storage_payload(row: Any) -> dict[str, Any]:
total = int(row["capacity_bytes"] or 0)
used = int(row["used_bytes"] or 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] = {
"storage": str(row["storage_id"]),
"type": str(row["storage_type"]),
"type": storage_type,
"shared": int(bool(row["shared"])),
"content": content_str,
"active": 1,
"enabled": 1,
"active": int(bool(config.get("active", True))),
"enabled": int(bool(config.get("enabled", True))),
"formats": formats,
"select_existing": int(bool(config.get("select_existing", False))),
"total": total,
"used": used,
"avail": avail,
+155 -24
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_config import register_cluster_config_handlers
from app.handlers.cluster_extra import register_cluster_extra_handlers
from app.handlers.common import require_node, subdirs
from app.handlers.common import cluster_metadata, require_node, subdirs
from app.handlers.firewall import register_firewall_handlers
from app.handlers.ha import register_ha_handlers
from app.handlers.legacy_aliases import register_legacy_aliases
@@ -60,10 +60,14 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
raise ApiError(401, "authentication failure")
key = settings.ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(username, key)
cluster_name = await _database(request).pool.fetchval(
"SELECT name FROM clusters ORDER BY created_at LIMIT 1"
)
return {
"username": username,
"ticket": ticket,
"CSRFPreventionToken": csrf_token(ticket, key),
"clustername": str(cluster_name or "pve-simulator"),
"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 {}
fingerprint = status_dict.get("ssl_fingerprint") or status_dict.get("fingerprint")
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:
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)
status = ops.get("status")
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 {
"status": str(row["status"]),
"node": str(row["name"]),
**payload,
"uptime": int(payload.get("uptime") or 0),
"wait": float(payload.get("wait") or 0.0),
"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]]:
@@ -197,6 +255,15 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
ops = await load_node_ops(request, name)
status_payload = ops.get("status")
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(
{
"type": "node",
@@ -205,10 +272,8 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"status": str(row["status"]),
"cpu": float(_num(status_dict.get("cpu"), 0.0)),
"maxcpu": int(_num(status_dict.get("maxcpu"), 4)),
"mem": int(
_num(status_dict.get("mem"), _num(status_dict.get("memory"), 0))
),
"maxmem": int(_num(status_dict.get("maxmem"), 8 * 1024**3)),
"mem": mem_val,
"maxmem": maxmem_val,
"uptime": int(_num(status_dict.get("uptime"), 0)),
"level": str(status_dict.get("level") or ""),
}
@@ -220,12 +285,40 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
elif type_filter == "storage":
kind_filter = ("storage",)
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"}:
kind_filter = (str(type_filter),)
else:
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:
rows = await _database(request).pool.fetch(
"""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")
running = status in {"running", "paused"}
vmid = int(external_id)
sid = f"{'vm' if kind == 'qemu' else 'ct'}:{external_id}"
item: dict[str, Any] = {
"type": kind,
"id": f"{kind}/{external_id}",
@@ -250,7 +344,7 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"vmid": vmid,
"name": str(state.get("name") or f"{kind}-{external_id}"),
"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),
"maxcpu": _maxcpu(state),
"mem": int(_num(state.get("mem"), 0)) if running else 0,
@@ -259,27 +353,64 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"maxdisk": int(_num(state.get("maxdisk"), 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)
elif kind == "storage":
content = state.get("content")
if isinstance(content, list):
content_text = ",".join(str(item) for item in content)
content_text = ",".join(str(part) for part in content)
else:
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(
{
"type": "storage",
"id": f"storage/{target}/{external_id}",
"node": target,
"storage": external_id,
"status": str(state.get("status") or "available"),
"content": content_text,
"disk": int(_num(state.get("disk"), 0)),
"maxdisk": int(_num(state.get("maxdisk"), 1 * 1024**3)),
"shared": shared,
"plugintype": str(
state.get("plugintype") or state.get("type") or "dir"
),
}
)
elif kind == "pool":
result.append(
{
"type": "storage",
"id": f"storage/{node}/{external_id}",
"node": node,
"storage": external_id,
"status": str(state.get("status") or "available"),
"content": content_text,
"disk": int(_num(state.get("disk"), 0)),
"maxdisk": int(_num(state.get("maxdisk"), 1 * 1024**3)),
"shared": int(_num(state.get("shared"), 0)),
"plugintype": str(
state.get("plugintype") or state.get("type") or "dir"
),
"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
+1 -2
View File
@@ -112,7 +112,7 @@ def register_firewall_handlers(registry: HandlerRegistry) -> None:
options = section.get("options", {})
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)
firewall = await _load_firewall(request)
section = _ensure_scope(firewall, scope_fn(payload))
@@ -123,7 +123,6 @@ def register_firewall_handlers(registry: HandlerRegistry) -> None:
current[key] = value
section["options"] = current
await _save_firewall(request, firewall)
return current
async def rules_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
+88 -29
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
import json
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)}
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:
async def ha_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("groups", "resources", "rules", "status")
@@ -41,17 +49,17 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
for row in rows:
payload = state(row["state"])
sid = str(row["external_id"])
result.append(
{
"sid": sid,
"type": "vm" if sid.startswith("vm:") else "ct",
"state": payload.get("state", "started"),
"group": payload.get("group"),
"node": str(row["node"]),
"max_relocate": payload.get("max_relocate", 1),
"max_restart": payload.get("max_restart", 1),
}
)
entry = {
"sid": sid,
"type": "vm" if sid.startswith("vm:") else "ct",
"state": payload.get("state", "started"),
"group": payload.get("group"),
"node": str(row["node"]),
"max_relocate": payload.get("max_relocate", 1),
"max_restart": payload.get("max_restart", 1),
}
entry["digest"] = _ha_digest(entry)
result.append(entry)
return result
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]]:
return subdirs("current", "manager_status")
async def ha_status_current(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"""SELECT count(*) FILTER (WHERE state->>'state' = 'started') AS started,
count(*) AS total
FROM resources WHERE kind='ha'"""
)
async def ha_status_current(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
master = await database(request).pool.fetchval(
"SELECT name FROM nodes WHERE status='online' ORDER BY name LIMIT 1"
)
metadata = await cluster_metadata(request)
ha_raw = metadata.get("ha")
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: dict[str, Any] = dict(status_raw) if isinstance(status_raw, dict) else {}
armed = bool(ha.get("armed")) if "armed" in ha else False
return {
"quorate": status.get("quorate", metadata.get("quorate", 0)),
"mode": status.get("mode", "active" if armed else "disabled"),
"master_node": status.get("master_node", str(master or "")),
"ha_started": int(row["started"] or 0),
"ha_total": int(row["total"] or 0),
"armed": 1 if armed else 0,
}
quorate = bool(int(status.get("quorate", metadata.get("quorate", 1)) or 1))
master_node = str(status.get("master_node") or master or "")
rows = await database(request).pool.fetch(
"""SELECT r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind='ha' ORDER BY r.external_id"""
)
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]:
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 []
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)
sid = str(payload["sid"])
target = str(payload.get("node") or payload.get("target") or "")
@@ -292,9 +346,14 @@ def register_ha_handlers(registry: HandlerRegistry) -> None:
ha_row["id"],
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:
await ha_relocate(request, inputs)
async def ha_migrate(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await ha_relocate(request, inputs)
async def ha_arm(request: Request, _inputs: dict[str, Any]) -> None:
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", "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(
"/cluster/backupinfo/not_backed_up",
"GET",
+110 -8
View File
@@ -38,27 +38,120 @@ def _state(value: object) -> dict[str, Any]:
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:
async def lxc_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(_values(inputs)["node"])
rows = await _database(request).pool.fetch(
"""SELECT r.external_id::integer AS vmid, r.state
FROM resources r JOIN nodes n ON n.id=r.node_id
"""SELECT r.external_id::integer AS vmid, r.state, c.config
FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN containers c ON c.resource_id=r.id
WHERE n.name=$1 AND r.kind='lxc' ORDER BY r.external_id::integer""",
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]:
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
row = await _lxc_resource(request, node, vmid)
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
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]:
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]:
return await lxc_config(request, inputs)
async def lxc_status(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
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:
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)
node, vmid = str(payload["node"]), str(payload["vmid"])
disk = str(payload.get("disk") or "rootfs")
@@ -446,6 +539,13 @@ def register_lxc_handlers(registry: HandlerRegistry) -> None:
resource["id"],
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:
payload = _values(inputs)
@@ -537,6 +637,8 @@ async def _create_task(
"lxc-clone": "pctclone",
"lxc-migrate": "pctmigrate",
"lxc-remote-migrate": "pctremote",
"lxc-resize": "pctresize",
"lxc-move-volume": "pctmove",
}[task_type]
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
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 []
async def move_volume(request: Request, inputs: dict[str, Any]) -> str:
from app.handlers.lxc import _create_task
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")
storage = str(values.get("storage") or "local-lvm")
config = _state(resource["config"])
@@ -60,7 +63,17 @@ def register_lxc_extra_handlers(registry: HandlerRegistry) -> None:
moves = state["volume_moves"] = []
moves.append({"volume": volume, "storage": storage})
await _save_state(request, resource["id"], state)
return f"UPID:{values['node']}:lxc-move-volume:{values['vmid']}"
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]:
values = _values(inputs)
+105 -16
View File
@@ -43,13 +43,59 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
packages = ops.get("apt", {}).get("packages", [])
return list(packages) if isinstance(packages, list) else []
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"])
ops = await load_node_ops(request, node)
repositories = ops.get("apt", {}).get("repositories", [])
return list(repositories) if isinstance(repositories, list) else []
repositories = ops.get("apt", {}).get("repositories")
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:
node = str(values(inputs)["node"])
@@ -89,7 +135,7 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
return item
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"])
payload = values(inputs)
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")
ops["network"] = updated
else:
# PUT /nodes/{node}/network without iface → reload (returns UPID string).
ops["network_applied"] = True
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]]:
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]]:
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"])
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
if not disk:
@@ -219,8 +271,9 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
disks["list"] = items
ops["disks"] = disks
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"])
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
if not disk:
@@ -243,14 +296,30 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
smart.pop(disk, None)
ops["disks"] = disks
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"])
ops = await load_node_ops(request, node)
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"])
service = str(values(inputs)["service"])
ops = await load_node_ops(request, node)
@@ -258,12 +327,25 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
if service not in services:
services[service] = {"state": "stopped", "enabled": 0}
await save_node_ops(request, node, ops)
payload = dict(services[service])
payload["service"] = service
return payload
return subdirs("state", "start", "stop", "restart", "reload")
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:
node = str(values(inputs)["node"])
@@ -286,7 +368,12 @@ def register_node_ops_handlers(registry: HandlerRegistry) -> None:
services[service] = current
ops["services"] = services
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/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:
import secrets
from app.api.errors import ApiError
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,
task_type=task_type,
payload={"node": node},
resource_key=f"node:{node}",
resource_key=f"node:{node}:{task_type}:{secrets.token_hex(4)}",
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
+203 -65
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:
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)
node = str(payload["node"])
await require_node(request, node)
@@ -134,9 +136,13 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
ops["disks"] = disks
disks[kind] = items
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)
node = str(payload["node"])
name = str(payload["name"])
@@ -148,6 +154,9 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
raise ApiError(404, f"{kind} does not exist")
ops.setdefault("disks", {})[kind] = remaining
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]:
payload = values(inputs)
@@ -194,11 +203,11 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
certs["acme"] = acme
ops["certificates"] = certs
await save_node_ops(request, node, ops)
if method == "DELETE":
return None
return await _node_task(request, node=node, task_type="acme", worker="acme")
async def certificates_custom(request: Request, inputs: dict[str, Any]) -> None:
async def certificates_custom(
request: Request, inputs: dict[str, Any]
) -> dict[str, Any] | None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
@@ -206,35 +215,40 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
certs = _certificates(ops)
if request.method.upper() == "DELETE":
certs["custom"] = None
else:
certificates = str(payload.get("certificates") or payload.get("cert") or "")
if not certificates:
raise ApiError(400, "parameter verification failed - 'certificates' missing")
key = str(payload.get("key") or payload.get("private-key") or "")
certs["custom"] = {
"certificates": certificates,
"key": key,
"restart": int(payload.get("restart") or 0),
"filename": str(payload.get("filename") or "pveproxy-ssl.pem"),
}
info = list(certs.get("info") or [])
info = [item for item in info if item.get("filename") != certs["custom"]["filename"]]
info.append(
{
"filename": certs["custom"]["filename"],
"fingerprint": secrets.token_hex(20),
"issuer": "CN=Simulator",
"subject": "CN=pve.local",
"notbefore": int(time.time()) - 86_400,
"notafter": int(time.time()) + 365 * 86_400,
"san": ["DNS:pve.local"],
"public-key-type": "rsa",
"public-key-bits": 2048,
}
)
certs["info"] = info
ops["certificates"] = certs
await save_node_ops(request, node, ops)
return None
certificates = str(payload.get("certificates") or payload.get("cert") or "")
if not certificates:
raise ApiError(400, "parameter verification failed - 'certificates' missing")
key = str(payload.get("key") or payload.get("private-key") or "")
filename = str(payload.get("filename") or "pveproxy-ssl.pem")
fingerprint = ":".join(secrets.token_hex(1) for _ in range(32)) # SHA-256 fingerprint style
summary = {
"filename": filename,
"fingerprint": fingerprint,
"issuer": "CN=Simulator",
"subject": "CN=pve.local",
"notbefore": int(time.time()) - 86_400,
"notafter": int(time.time()) + 365 * 86_400,
"san": ["DNS:pve.local"],
"public-key-type": "rsa",
"public-key-bits": 2048,
"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
ops["certificates"] = certs
await save_node_ops(request, node, ops)
return summary
async def certificates_info(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
@@ -257,7 +271,39 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
certs["info"] = info
ops["certificates"] = certs
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]]:
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):
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()
if method == "POST":
entry = {
handle = payload.get("handle")
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
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")
entry.setdefault("enabled", 1)
repositories.append(entry)
path = str(
payload.get("path") or f"/etc/apt/sources.list.d/sim-{secrets.token_hex(2)}.list"
)
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:
path = payload.get("path")
handle = payload.get("handle")
index = payload.get("index")
updated: list[dict[str, Any]] = []
for idx, item in enumerate(repositories):
match = False
if path is not None and item.get("path") == path:
match = True
if handle is not None and item.get("handle") == handle:
match = True
if index is not None and idx == int(index):
match = True
if match or (path is None and handle is None and index is None and idx == 0):
merged = {
**item,
**{
key: value
for key, value in payload.items()
if key not in {"node", "delete", "digest", "path", "handle", "index"}
},
}
updated.append(merged)
else:
updated.append(item)
repositories = updated
apt["repositories"] = repositories
for file_idx, file_entry in enumerate(files):
if path is not None and file_entry.get("path") != path:
continue
repos = [
dict(item)
for item in list(file_entry.get("repositories") or [])
if isinstance(item, dict)
]
if index is not None:
idx = int(index)
if 0 <= idx < len(repos):
repos[idx] = {
**repos[idx],
**{
key: value
for key, value in payload.items()
if key
not in {
"node",
"delete",
"digest",
"path",
"handle",
"index",
"file-type",
}
},
}
elif path is not None or file_idx == 0:
if repos:
repos[0] = {
**repos[0],
**{
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
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
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(
"""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
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,
)
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:
@@ -39,16 +56,25 @@ def register_pool_handlers(registry: HandlerRegistry) -> None:
metadata = state(row["metadata"])
members = await _pool_members(request, row["id"])
if not members and isinstance(metadata.get("members"), list):
members = [str(item) for item in metadata["members"]]
item: dict[str, Any] = {
# Fallback for metadata-only membership before pool_members rows exist.
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"]),
"members": members,
}
if row["comment"] is not None:
item["comment"] = str(row["comment"])
pool_item["comment"] = str(row["comment"])
elif metadata.get("comment"):
item["comment"] = str(metadata["comment"])
result.append(item)
pool_item["comment"] = str(metadata["comment"])
result.append(pool_item)
return result
async def pool_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
+135 -44
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]:
"""Wire-format QEMU config for clients (bpg / pulumi-proxmoxve)."""
import hashlib
merged = {**state, **config}
payload: dict[str, Any] = {}
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
agent = config.get("agent", state.get("agent", "0"))
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
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:
async def qemu_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(_values(inputs)["node"])
rows = await _database(request).pool.fetch(
"""SELECT r.external_id::integer AS vmid, r.state
FROM resources r JOIN nodes n ON n.id=r.node_id
"""SELECT r.external_id::integer AS vmid, r.state, v.config
FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN virtual_machines v ON v.resource_id=r.id
WHERE n.name=$1 AND r.kind='qemu' ORDER BY r.external_id::integer""",
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]]:
payload = _values(inputs)
@@ -127,28 +176,28 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
else 0,
)
)
return {
"vmid": vmid,
"name": str(config.get("name", f"vm-{vmid}")),
"status": status,
"qmpstatus": status if running else "stopped",
"lock": str(vm_state.get("lock", "")),
"pid": int(vm_state.get("pid", 12_345 if running else 0)),
"cpus": int(config.get("cores", config.get("cpus", 1))),
"maxmem": maxmem,
"mem": mem_used,
"balloon": int(vm_state.get("balloon", 0)),
"ballooninfo": {
"actual": mem_used,
"max_mem": maxmem,
"mem_swapped_in": 0,
"mem_swapped_out": 0,
},
"uptime": uptime,
"template": int(bool(vm_state.get("template", False))),
"ha": {"managed": int(vm_state.get("ha_managed", 0))},
"agent": 1 if running and str(config.get("agent", "0")).startswith("1") else 0,
}
agent_enabled = str(config.get("agent", "0")).startswith("1")
item = _qemu_index_item(vmid, vm_state, config)
item.update(
{
"uptime": uptime,
"mem": mem_used,
"balloon": int(vm_state.get("balloon", 0)),
"ballooninfo": {
"actual": mem_used,
"max_mem": maxmem,
"mem_swapped_in": 0,
"mem_swapped_out": 0,
},
"template": bool(vm_state.get("template", False)),
"ha": {"managed": int(vm_state.get("ha_managed", 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]:
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
@@ -339,24 +388,30 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
rows = await _database(request).pool.fetch(
"""SELECT name, parent_name, description, created_at FROM snapshots
"""SELECT name, parent_name, description, created_at, state FROM snapshots
WHERE resource_id=$1 ORDER BY created_at, name""",
resource["id"],
)
return [
{
"name": row["name"],
"parent": row["parent_name"],
"description": row["description"] or "",
"snaptime": int(row["created_at"].timestamp()),
}
for row in rows
]
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"],
"parent": row["parent_name"],
"description": row["description"] or "",
"snaptime": int(row["created_at"].timestamp()),
"vmstate": int(bool(snap_state.get("vmstate"))),
}
)
return result
async def snapshot_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
row = await _snapshot(request, values)
state = _state(row["state"])
raw_state = row["state"] if "state" in row else None
state = _state(raw_state) if raw_state is not None else {}
return {
"name": row["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)
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
resource = await _qemu_resource(request, node, vmid)
@@ -561,6 +616,13 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
str(config[disk]).split(":", 1)[0],
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:
values = _values(inputs)
@@ -635,12 +697,32 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
return await agent_result("ping", request, inputs)
async def task_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
tasks = await TaskRepository(_database(request).pool).list_for_node(
str(_values(inputs)["node"])
)
return [
{"upid": task.upid, "status": task.status, "type": task.task_type} for task in tasks
]
from app.tasks.upid import Upid
node = str(_values(inputs)["node"])
tasks = await TaskRepository(_database(request).pool).list_for_node(node)
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]:
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)
node, vmid = str(payload["node"]), str(payload["vmid"])
resource = await _qemu_resource(request, node, vmid)
@@ -698,6 +780,13 @@ def register_qemu_handlers(registry: HandlerRegistry) -> None:
resource["id"],
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]]:
payload = _values(inputs)
@@ -815,6 +904,8 @@ async def _create_task(
"qemu-migrate": "qmigrate",
"qemu-remote-migrate": "qmremote",
"qemu-move-disk": "qmmove",
"qemu-resize": "qmresize",
"qemu-template": "qmtemplate",
}[task_type]
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
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 {}
_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]:
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]]:
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())]
@@ -59,7 +110,9 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
"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)
metadata, sdn = await _load(request, for_write=True)
lock = sdn.get("lock")
@@ -71,15 +124,16 @@ def register_sdn_handlers(registry: HandlerRegistry) -> None:
if payload.get("release-lock"):
sdn["lock"] = None
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)
if sdn.get("lock") and not values(inputs).get("allow-pending"):
raise ApiError(400, "SDN is already locked")
token = secrets.token_hex(8)
sdn["lock"] = {"token": token}
await save_cluster_metadata(request, metadata)
return {"digest": token, "token": token}
return token
async def lock_delete(request: Request, inputs: dict[str, Any]) -> None:
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 nodes n ON n.id = r.node_id
WHERE n.name=$1 OR s.shared = true
OR s.storage_id IN ('local', 'local-lvm')
ORDER BY s.storage_id""",
node,
)
@@ -294,7 +295,55 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
if status != "DELETE 1":
raise ApiError(404, "volume does not exist")
async def node_storage_upload(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
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"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
@@ -326,11 +375,11 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
size,
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(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]] | None:
) -> list[dict[str, Any]] | str:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
@@ -340,6 +389,8 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
storage_id,
)
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)
await database(request).pool.execute(
"""DELETE FROM backups
@@ -352,7 +403,9 @@ def register_storage_handlers(registry: HandlerRegistry) -> None:
resource_id,
keep,
)
return None
return await _node_task(
request, node=node, task_type="prune-backups", worker="prunebackups"
)
rows = await database(request).pool.fetch(
"""SELECT volume_id, size_bytes, created_at FROM backups
WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
@@ -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}/status", "GET", node_storage_status)
registry.register("/nodes/{node}/storage/{storage}/content", "GET", node_storage_content)
registry.register("/nodes/{node}/storage/{storage}/content", "POST", node_storage_upload)
registry.register("/nodes/{node}/storage/{storage}/content", "POST", node_storage_allocate)
registry.register(
"/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-suspend": qemu,
"qemu-update": qemu,
"qemu-resize": qemu,
"qemu-template": qemu,
"lxc-clone": lxc,
"lxc-create": lxc,
"lxc-delete": lxc,
"lxc-migrate": lxc,
"lxc-reboot": lxc,
"lxc-resume": lxc,
"lxc-resize": lxc,
"lxc-shutdown": lxc,
"lxc-snapshot-create": lxc,
"lxc-snapshot-delete": lxc,
@@ -83,6 +86,29 @@ def create_app(
"lxc-suspend": lxc,
"vzdump": 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,
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}
+1031 -211
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.pool import AsyncpgDatabase
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
_BUNDLED_9 = Path(
@@ -119,7 +119,7 @@ async def prepare_db(url: str) -> None:
connection = await asyncpg.connect(url)
try:
await migrate(connection)
await apply_seed(connection, small_profile())
await apply_seed(connection, lab_profile())
finally:
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")
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"])
vmids = [str(item) for item in task.payload.get("vmids", [])]
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":
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:
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
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)
if operation == "move-disk":
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:
row = await connection.fetchrow("SELECT state FROM resources WHERE id=$1", resource_id)
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.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.model import Method, Parameter, 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))
def _parameter_payload(parameter: Parameter) -> dict[str, object]:
def _parameter_payload(parameter: Parameter, *, wire_name: str | None = None) -> dict[str, object]:
schema = parameter.definition
return {
"name": parameter.name,
name = wire_name or parameter.name
typetext = schema.extra.get("typetext")
payload: dict[str, object] = {
"name": name,
"type": schema.type,
"description": schema.description,
"optional": bool(schema.optional),
"enum": list(schema.enum),
"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(
@@ -274,15 +283,20 @@ def method_payload(
"example": path_param_example(name) or name,
}
)
body_fields = [
contract_body_fields = [
_parameter_payload(parameter)
for parameter in method.parameters
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 = [
_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)
# 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)
implemented = (
(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]:
"""Minimal request body: required parameters only, Proxmox wire names/values."""
body: dict[str, object] = {}
for parameter in method.parameters:
if parameter.name in path_params:
continue
if "[n]" in parameter.name:
concrete = parameter.name.replace("[n]", "0")
if not parameter.definition.optional:
body[concrete] = schema_example(parameter.definition, name=concrete)
continue
if parameter.definition.optional:
continue
body[parameter.name] = schema_example(parameter.definition, name=parameter.name)
wire = wire_param_name(parameter.name)
body[wire] = schema_example(parameter.definition, name=parameter.name)
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)
def default_store_root() -> Path:
return _DEFAULT_STORE
+330 -53
View File
@@ -583,6 +583,123 @@
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 {
background: var(--brand-accent);
border-color: var(--brand-accent);
@@ -5371,11 +5488,45 @@
function buildDemoDataHtml(data) {
const loaded = Boolean(data?.loaded);
const profile = data?.profile ?? "minimal";
const badgeLabel = loaded ? String(profile) : "Minimal";
const cephPiB = data?.ceph_capacity_pib;
const cephText = cephPiB != null && cephPiB > 0 ? `${cephPiB} PiB` : "—";
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 = [
["Profile", data?.profile ?? "—", loaded ? "ok" : ""],
["Profile", profile, loaded ? "ok" : ""],
["Cluster", data?.cluster_name ?? "—", ""],
["Nodes", data?.nodes ?? "—", ""],
["QEMU", data?.qemu ?? "—", ""],
@@ -5390,13 +5541,12 @@
return `
<div class="help-demo-panel">
<div class="help-report-head help-demo-head">
<h3>Demo cluster</h3>
<p>Load a realistic enterprise environment into the emulator database for hands-on API exploration.</p>
<span class="help-demo-badge ${loaded ? "loaded" : "empty"}">${loaded ? "Loaded" : "Empty"}</span>
<h3>Proxmox demo cluster</h3>
<p>Pick a cluster size — inventory (nodes, guests, Ceph, HA, backups, tasks) scales together.</p>
<span class="help-demo-badge ${loaded ? "loaded" : "empty"}">${escapeHtml(badgeLabel)}</span>
</div>
<p class="help-demo-note">
20 nodes · 1000 guests (850 QEMU + 150 LXC) · Ceph 300 OSD (~5 PiB) · backups, logs,
snapshots, pools, HA rules, and multi-level users. Loading replaces current simulation state.
Password for seeded users: <code>secret</code>. Loading replaces current Proxmox state.
</p>
<div class="help-stat-grid">
${stats.map(([label, value, tone]) => `
@@ -5406,9 +5556,30 @@
</div>
`).join("")}
</div>
<div class="help-demo-actions">
<button class="btn btn-sm btn-demo-load" id="btn-demo-load" type="button">Load demo cluster</button>
<button class="btn btn-sm btn-demo-unload" id="btn-demo-unload" type="button">Remove demo data</button>
<div class="demo-size-grid" role="group" aria-label="Cluster sizes">
${sizes.map((s) => {
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>
</div>
</div>
@@ -5446,35 +5617,45 @@
renderDemoState(await res.json());
}
async function loadDemoData() {
const loadBtn = document.getElementById("btn-demo-load");
if (loadBtn) loadBtn.disabled = true;
async function loadDemoData(size = "small") {
const loadBtns = document.querySelectorAll("[data-demo-size]");
loadBtns.forEach((btn) => { btn.disabled = true; });
setLoading(true);
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(() => ({}));
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 || {});
if (state.ticket) syncAuthCookie(state.ticket);
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) {
toast("Sign in (root@pam / secret) to call API endpoints", "warn");
}
} finally {
if (loadBtn) loadBtn.disabled = false;
loadBtns.forEach((btn) => { btn.disabled = false; });
setLoading(false);
}
}
async function unloadDemoData() {
const confirmed = await showConfirm({
title: "Remove demo data?",
message: "This wipes simulator DB state (demo data and anything created via the API), then reloads a minimal cluster.",
confirmLabel: "Remove demo data",
cancelLabel: "Keep demo data",
title: "Reset to minimal?",
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: "Reset to minimal",
cancelLabel: "Keep current data",
tone: "danger",
});
if (!confirmed) return;
@@ -5489,7 +5670,7 @@
}
renderDemoState(body.summary || {});
refreshOverview();
toast("Demo data removed", "info");
toast("Reset to minimal cluster", "info");
} finally {
if (unloadBtn) unloadBtn.disabled = false;
setLoading(false);
@@ -5730,17 +5911,21 @@
if (!els.dataPanel || els.dataPanel.dataset.demoBound) return;
els.dataPanel.dataset.demoBound = "1";
els.dataPanel.addEventListener("click", (event) => {
const target = event.target.closest("#btn-demo-load, #btn-demo-unload, #btn-demo-refresh");
if (!target) return;
if (target.id === "btn-demo-load") {
loadDemoData().catch((error) => {
const loadBtn = event.target.closest("[data-demo-size]");
if (loadBtn) {
const size = loadBtn.getAttribute("data-demo-size") || "small";
loadDemoData(size).catch((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) => {
showError(String(error));
toast("Failed to remove demo data", "error");
toast("Failed to reset to minimal", "error");
});
} else if (target.id === "btn-demo-refresh") {
renderDataPanel().catch((error) => showError(String(error)));
@@ -6281,12 +6466,24 @@
}
container.innerHTML = fields.map((f) => {
const stored = kind === "path" ? state.pathValues[f.name] : state.bodyValues[f.name];
const val = stored ?? f.example ?? "";
const desc = f.description ? `<div style="font-size:11px;color:var(--muted);margin-top:2px">${f.description}</div>` : "";
const fallback = kind === "path" || !f.optional ? formatParamExample(f.example) : "";
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>
<td><code>${f.name}</code>${desc}</td>
<td><code>${escapeHtml(f.name)}</code>${desc}</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>`;
}).join("");
container.querySelectorAll("input[data-field]").forEach((input) => {
@@ -6393,9 +6590,11 @@
try {
const parsed = JSON.parse(body || "{}");
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const { inner } = unwrapBodyExample(parsed);
for (const field of bodyFields) {
if (Object.prototype.hasOwnProperty.call(parsed, field.name)) {
state.bodyValues[field.name] = String(parsed[field.name] ?? "");
const value = getByPath(inner, wireBodyFieldName(field.name));
if (value !== undefined) {
state.bodyValues[field.name] = formatParamExample(value);
}
}
}
@@ -6468,31 +6667,106 @@
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 } = {}) {
if (!state.method) return;
const inputs = [...(els.bodyFields?.querySelectorAll("input[data-field]") || [])];
const body = {};
const example = state.method.body_example;
const { wrapKey, inner } = unwrapBodyExample(example);
const root = deepCloneJson(inner) || {};
let touched = false;
inputs.forEach((input) => {
const name = input.dataset.field;
const name = wireBodyFieldName(input.dataset.field);
if (!name) return;
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;
if (example && typeof example === "object" && !Array.isArray(example)) {
for (const [key, value] of Object.entries(example)) {
if (value === undefined || value === null || value === "") continue;
body[key] = value;
}
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;
}
// Keep empty object out of the editor — do not show `{}`.
const body = wrapKey ? { [wrapKey]: root } : root;
els.body.value = Object.keys(body).length ? JSON.stringify(body, null, 2) : "";
updateBodyHighlight();
if (syncVisibility) {
@@ -6547,7 +6821,10 @@
const bodyFields = [...(d.body_fields || []), ...(d.indexed_fields || [])];
state.bodyValues = {};
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;
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)
async def ui_demo_load(request: Request) -> JSONResponse:
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:
await apply_seed(connection, profile)
summary = await simulation_state_summary(connection)
@@ -179,7 +195,7 @@ async def ui_demo_unload(request: Request) -> JSONResponse:
summary = await simulation_state_summary(connection)
except Exception as error:
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
return JSONResponse({"ok": True, "profile": profile.name, "summary": summary})