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:
+85
-14
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user