Initial commit: VMware vSphere API simulator scaffold.

Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API
contracts, docs, client examples, and the unit/integration/compatibility
test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Semantic handlers for implemented Proxmox methods."""
+747
View File
@@ -0,0 +1,747 @@
"""Persistent Proxmox API-token lifecycle handlers."""
from __future__ import annotations
import json
import secrets
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.access_auth import register_access_auth_handlers
from app.handlers.common import database, state, subdirs, values
from app.security.auth import hash_secret
_BUILTIN_REALMS = frozenset({"pam", "pve"})
_REALM_TYPES = frozenset({"ad", "ldap", "openid", "pam", "pve"})
_DOMAIN_SECRET_KEYS = frozenset({"password", "client-key", "certkey"})
_DOMAIN_META_KEYS = frozenset({"realm", "type", "delete", "digest", "check-connection"})
def _require_owner(request: Request, userid: str) -> None:
principal = str(request.state.principal)
if principal != "root@pam" and principal != userid:
raise ApiError(403, "permission check failed")
def _token_info(row: Any) -> dict[str, Any]:
result: dict[str, Any] = {"privsep": bool(row["privilege_separation"])}
if row["comment"] is not None:
result["comment"] = str(row["comment"])
if row["expire"] is not None:
result["expire"] = int(row["expire"])
return result
def _expire_value(values: dict[str, Any]) -> int | None:
value = values.get("expire")
return value if isinstance(value, int) and not isinstance(value, bool) else None
def _api_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off", ""}:
return False
raise ApiError(400, f"invalid boolean value: {value}")
def _domain_config_value(value: object) -> object:
if isinstance(value, bool):
return int(value)
return value
def _domain_payload(name: str, kind: str, config: object) -> dict[str, Any]:
payload: dict[str, Any] = {"realm": name, "type": kind}
for key, value in state(config).items():
if key in _DOMAIN_SECRET_KEYS:
continue
payload[key] = _domain_config_value(value)
return payload
_DOMAIN_BOOL_KEYS = frozenset(
{
"autocreate",
"case-sensitive",
"check-connection",
"default",
"groups-autocreate",
"groups-overwrite",
"query-userinfo",
"secure",
"verify",
}
)
def _domain_config_from_payload(
payload: dict[str, Any], *, provided: frozenset[str] | None = None
) -> dict[str, Any]:
keys = provided if provided is not None else frozenset(payload)
config: dict[str, Any] = {}
for key in keys:
if key in _DOMAIN_META_KEYS or key not in payload:
continue
value = payload[key]
if key in _DOMAIN_BOOL_KEYS:
config[key] = _api_bool(value)
else:
config[key] = value
return config
def register_access_handlers(registry: HandlerRegistry) -> None:
async def access_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs(
"acl",
"domains",
"groups",
"openid",
"password",
"permissions",
"roles",
"tfa",
"ticket",
"users",
)
async def user_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT p.name, p.realm_name, p.password_hash IS NOT NULL AS enabled,
COALESCE(r.kind, p.realm_name) AS realm_kind
FROM principals p
LEFT JOIN realms r ON r.name = p.realm_name
ORDER BY p.name"""
)
return [
{
"userid": str(row["name"]),
"enable": 1 if row["enabled"] else 0,
"realm-type": str(row["realm_kind"]),
}
for row in rows
]
async def user_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid = str(payload["userid"])
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)",
userid,
)
if exists:
raise ApiError(409, "user already exists")
realm = userid.split("@", 1)[1] if "@" in userid else "pve"
realm_exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)",
realm,
)
if not realm_exists:
raise ApiError(400, f"authentication realm '{realm}' does not exist")
password = payload.get("password")
await database(request).pool.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES(gen_random_uuid(), $1, $2, $3)""",
userid,
hash_secret(str(password)) if password else None,
realm,
)
async def user_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
userid = str(values(inputs)["userid"])
row = await database(request).pool.fetchrow(
"""SELECT p.name, p.realm_name, p.password_hash IS NOT NULL AS enabled,
COALESCE(r.kind, p.realm_name) AS realm_kind
FROM principals p
LEFT JOIN realms r ON r.name = p.realm_name
WHERE p.name=$1""",
userid,
)
if row is None:
raise ApiError(404, "user does not exist")
return {
"userid": str(row["name"]),
"enable": 1 if row["enabled"] else 0,
"realm-type": str(row["realm_kind"]),
}
async def user_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid = str(payload["userid"])
provided = frozenset(str(item) for item in inputs.get("provided", payload))
row = await database(request).pool.fetchrow(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if row is None:
raise ApiError(404, "user does not exist")
if "password" in provided and payload.get("password"):
await database(request).pool.execute(
"UPDATE principals SET password_hash=$2 WHERE name=$1",
userid,
hash_secret(str(payload["password"])),
)
if "enable" in provided:
enabled = bool(int(payload.get("enable", 1)))
if enabled and payload.get("password"):
pass
elif not enabled:
await database(request).pool.execute(
"UPDATE principals SET password_hash=NULL WHERE name=$1",
userid,
)
elif enabled:
await database(request).pool.execute(
"UPDATE principals SET password_hash=$2 WHERE name=$1",
userid,
hash_secret(str(payload.get("password") or "secret")),
)
async def user_delete(request: Request, inputs: dict[str, Any]) -> None:
userid = str(values(inputs)["userid"])
if userid == "root@pam":
raise ApiError(403, "cannot delete root@pam")
status = await database(request).pool.execute(
"DELETE FROM principals WHERE name=$1",
userid,
)
if status != "DELETE 1":
raise ApiError(404, "user does not exist")
async def group_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT g.group_id, g.comment,
COALESCE(
array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL),
'{}'
) AS users
FROM identity_groups g
LEFT JOIN identity_group_members gm ON gm.group_id = g.id
LEFT JOIN principals p ON p.id = gm.principal_id
GROUP BY g.id, g.group_id, g.comment
ORDER BY g.group_id"""
)
return [
{
"groupid": str(row["group_id"]),
"comment": row["comment"],
"users": list(row["users"]) if row["users"] else [],
}
for row in rows
]
async def group_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
groupid = str(payload["groupid"])
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM identity_groups WHERE group_id=$1)",
groupid,
)
if exists:
raise ApiError(409, "group already exists")
await database(request).pool.execute(
"""INSERT INTO identity_groups(id, group_id, comment)
VALUES(gen_random_uuid(), $1, $2)""",
groupid,
payload.get("comment"),
)
async def group_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
groupid = str(values(inputs)["groupid"])
row = await database(request).pool.fetchrow(
"""SELECT g.group_id, g.comment,
COALESCE(
array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL),
'{}'
) AS users
FROM identity_groups g
LEFT JOIN identity_group_members gm ON gm.group_id = g.id
LEFT JOIN principals p ON p.id = gm.principal_id
WHERE g.group_id=$1
GROUP BY g.id, g.group_id, g.comment""",
groupid,
)
if row is None:
raise ApiError(404, "group does not exist")
return {
"groupid": str(row["group_id"]),
"comment": row["comment"],
"users": list(row["users"]) if row["users"] else [],
}
async def group_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
groupid = str(payload["groupid"])
row = await database(request).pool.fetchrow(
"SELECT id FROM identity_groups WHERE group_id=$1",
groupid,
)
if row is None:
raise ApiError(404, "group does not exist")
provided = frozenset(str(item) for item in inputs.get("provided", payload))
if "comment" in provided:
await database(request).pool.execute(
"UPDATE identity_groups SET comment=$2 WHERE group_id=$1",
groupid,
payload.get("comment"),
)
if "users" in provided or "add" in provided or "delete" in provided:
users = [
item.strip() for item in str(payload.get("users", "")).split(",") if item.strip()
]
add = [item.strip() for item in str(payload.get("add", "")).split(",") if item.strip()]
delete = [
item.strip() for item in str(payload.get("delete", "")).split(",") if item.strip()
]
if users:
await database(request).pool.execute(
"DELETE FROM identity_group_members WHERE group_id=$1",
row["id"],
)
for userid in users:
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if principal_id is None:
raise ApiError(404, f"user {userid} does not exist")
await database(request).pool.execute(
"""INSERT INTO identity_group_members(group_id, principal_id)
VALUES($1, $2) ON CONFLICT DO NOTHING""",
row["id"],
principal_id,
)
for userid in add:
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if principal_id is None:
raise ApiError(404, f"user {userid} does not exist")
await database(request).pool.execute(
"""INSERT INTO identity_group_members(group_id, principal_id)
VALUES($1, $2) ON CONFLICT DO NOTHING""",
row["id"],
principal_id,
)
for userid in delete:
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if principal_id is None:
raise ApiError(404, f"user {userid} does not exist")
await database(request).pool.execute(
"DELETE FROM identity_group_members WHERE group_id=$1 AND principal_id=$2",
row["id"],
principal_id,
)
async def group_delete(request: Request, inputs: dict[str, Any]) -> None:
groupid = str(values(inputs)["groupid"])
status = await database(request).pool.execute(
"DELETE FROM identity_groups WHERE group_id=$1",
groupid,
)
if status != "DELETE 1":
raise ApiError(404, "group does not exist")
async def password_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid = str(payload.get("userid") or request.state.principal)
principal = str(request.state.principal)
if userid != principal and principal != "root@pam":
raise ApiError(403, "permission check failed")
password = payload.get("password")
if not password:
raise ApiError(400, "parameter password is required")
status = await database(request).pool.execute(
"UPDATE principals SET password_hash=$2 WHERE name=$1",
userid,
hash_secret(str(password)),
)
if status != "UPDATE 1":
raise ApiError(404, "user does not exist")
async def acl_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT p.name AS ugid, 'user' AS type, a.role_name AS roleid, a.path, a.propagate
FROM acl_entries a JOIN principals p ON p.id=a.principal_id
UNION ALL
SELECT g.group_id AS ugid, 'group' AS type, a.role_name AS roleid, a.path, a.propagate
FROM group_acl_entries a JOIN identity_groups g ON g.id=a.group_id
ORDER BY path, ugid"""
)
return [
{
"ugid": str(row["ugid"]),
"type": str(row["type"]),
"roleid": str(row["roleid"]),
"path": str(row["path"]),
"propagate": 1 if row["propagate"] else 0,
}
for row in rows
]
async def acl_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
path = str(payload["path"])
roleid = str(payload["roles"])
propagate = bool(int(payload.get("propagate", 1)))
users = [item.strip() for item in str(payload.get("users", "")).split(",") if item.strip()]
groups = [
item.strip() for item in str(payload.get("groups", "")).split(",") if item.strip()
]
for userid in users:
principal_id = await database(request).pool.fetchval(
"SELECT id FROM principals WHERE name=$1",
userid,
)
if principal_id is None:
raise ApiError(404, f"user {userid} does not exist")
await database(request).pool.execute(
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""",
roleid,
)
await database(request).pool.execute(
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate)
VALUES($1, $2, $3, $4)
ON CONFLICT (principal_id, role_name, path) DO UPDATE
SET propagate=EXCLUDED.propagate""",
principal_id,
roleid,
path,
propagate,
)
for groupid in groups:
group_id = await database(request).pool.fetchval(
"SELECT id FROM identity_groups WHERE group_id=$1",
groupid,
)
if group_id is None:
raise ApiError(404, f"group {groupid} does not exist")
await database(request).pool.execute(
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""",
roleid,
)
await database(request).pool.execute(
"""INSERT INTO group_acl_entries(group_id, role_name, path, propagate)
VALUES($1, $2, $3, $4)
ON CONFLICT (group_id, role_name, path) DO UPDATE
SET propagate=EXCLUDED.propagate""",
group_id,
roleid,
path,
propagate,
)
async def token_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
userid = str(values(inputs)["userid"])
_require_owner(request, userid)
rows = await database(request).pool.fetch(
"""SELECT t.token_id, t.comment, t.privilege_separation,
extract(epoch from t.expires_at)::bigint AS expire
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
WHERE p.name=$1 ORDER BY t.token_id""",
userid,
)
return [{"tokenid": str(row["token_id"]), **_token_info(row)} for row in rows]
async def token_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
row = await database(request).pool.fetchrow(
"""SELECT t.comment, t.privilege_separation,
extract(epoch from t.expires_at)::bigint AS expire
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
WHERE p.name=$1 AND t.token_id=$2""",
userid,
tokenid,
)
if row is None:
raise ApiError(404, "API token does not exist")
return _token_info(row)
async def token_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
secret = secrets.token_urlsafe(32)
row = await database(request).pool.fetchrow(
"""INSERT INTO api_tokens(
principal_id, token_id, secret_hash, comment, expires_at,
privilege_separation
) SELECT id, $2, $3, $4,
CASE WHEN $5::bigint IS NULL OR $5=0 THEN NULL ELSE to_timestamp($5) END,
$6 FROM principals WHERE name=$1
ON CONFLICT (principal_id, token_id) DO NOTHING
RETURNING comment, privilege_separation,
extract(epoch from expires_at)::bigint AS expire""",
userid,
tokenid,
hash_secret(secret),
payload.get("comment"),
_expire_value(payload),
bool(payload.get("privsep", True)),
)
if row is None:
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)", userid
)
raise ApiError(409 if exists else 404, "user or API token conflict")
return {"full-tokenid": f"{userid}!{tokenid}", "info": _token_info(row), "value": secret}
async def token_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
provided = frozenset(str(item) for item in inputs.get("provided", payload))
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
regenerate = bool(payload.get("regenerate", False))
secret = secrets.token_urlsafe(32) if regenerate else None
row = await database(request).pool.fetchrow(
"""UPDATE api_tokens t SET
comment=COALESCE($3::text, comment),
expires_at=CASE WHEN $4::bigint IS NULL THEN expires_at
WHEN $4=0 THEN NULL ELSE to_timestamp($4) END,
privilege_separation=COALESCE($5::boolean, privilege_separation),
secret_hash=COALESCE($6::text, secret_hash), updated_at=now()
FROM principals p WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2
RETURNING t.comment, t.privilege_separation,
extract(epoch from t.expires_at)::bigint AS expire""",
userid,
tokenid,
payload.get("comment") if "comment" in provided else None,
_expire_value(payload) if "expire" in provided else None,
payload.get("privsep") if "privsep" in provided else None,
hash_secret(secret) if secret is not None else None,
)
if row is None:
raise ApiError(404, "API token does not exist")
result = _token_info(row)
if secret is not None:
result.update({"full-tokenid": f"{userid}!{tokenid}", "value": secret})
return result
async def token_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
_require_owner(request, userid)
status = await database(request).pool.execute(
"""DELETE FROM api_tokens t USING principals p
WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2""",
userid,
tokenid,
)
if status != "DELETE 1":
raise ApiError(404, "API token does not exist")
async def role_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"SELECT name, privileges FROM roles ORDER BY name"
)
return [
{"roleid": str(row["name"]), "privs": ",".join(str(item) for item in row["privileges"])}
for row in rows
]
async def role_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
roleid = str(values(inputs)["roleid"])
row = await database(request).pool.fetchrow(
"SELECT name, privileges FROM roles WHERE name=$1",
roleid,
)
if row is None:
raise ApiError(404, "role does not exist")
return {
"roleid": str(row["name"]),
"privs": ",".join(str(item) for item in row["privileges"]),
}
async def role_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
roleid = str(payload["roleid"])
privs = [item.strip() for item in str(payload.get("privs", "")).split(",") if item.strip()]
await database(request).pool.execute(
"""INSERT INTO roles(name, privileges) VALUES($1, $2)
ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""",
roleid,
privs,
)
async def role_update(request: Request, inputs: dict[str, Any]) -> None:
await role_create(request, inputs)
async def role_delete(request: Request, inputs: dict[str, Any]) -> None:
roleid = str(values(inputs)["roleid"])
status = await database(request).pool.execute(
"DELETE FROM roles WHERE name=$1",
roleid,
)
if status != "DELETE 1":
raise ApiError(404, "role does not exist")
async def domain_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"SELECT name, kind, config FROM realms ORDER BY name"
)
return [_domain_payload(str(row["name"]), str(row["kind"]), row["config"]) for row in rows]
async def domain_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
realm = str(values(inputs)["realm"])
row = await database(request).pool.fetchrow(
"SELECT name, kind, config FROM realms WHERE name=$1",
realm,
)
if row is None:
raise ApiError(404, "realm does not exist")
return _domain_payload(str(row["name"]), str(row["kind"]), row["config"])
async def domain_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
realm = str(payload["realm"])
realm_type = str(payload.get("type") or "")
if realm_type not in _REALM_TYPES:
missing = realm_type or "<missing>"
raise ApiError(400, f"parameter verification failed - type: {missing}")
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)",
realm,
)
if exists:
raise ApiError(400, f"realm '{realm}' already exists")
config = _domain_config_from_payload(payload)
if config.get("default"):
await database(request).pool.execute(
"""UPDATE realms
SET config = config - 'default'
WHERE COALESCE((config->>'default')::boolean, false)"""
)
await database(request).pool.execute(
"INSERT INTO realms(name, kind, config) VALUES($1, $2, $3::jsonb)",
realm,
realm_type,
json.dumps(config, sort_keys=True),
)
async def domain_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
realm = str(payload["realm"])
provided = frozenset(str(item) for item in inputs.get("provided", payload))
row = await database(request).pool.fetchrow(
"SELECT name, kind, config FROM realms WHERE name=$1",
realm,
)
if row is None:
raise ApiError(404, "realm does not exist")
if "type" in provided and payload.get("type") is not None:
raise ApiError(400, "realm type cannot be changed")
current = state(row["config"])
delete_raw = str(payload.get("delete") or "")
for key in [item.strip() for item in delete_raw.split(",") if item.strip()]:
current.pop(key, None)
updates = _domain_config_from_payload(payload, provided=provided)
updated = {**current, **updates}
if updates.get("default"):
await database(request).pool.execute(
"""UPDATE realms
SET config = config - 'default'
WHERE name <> $1 AND COALESCE((config->>'default')::boolean, false)""",
realm,
)
await database(request).pool.execute(
"UPDATE realms SET config=$2::jsonb WHERE name=$1",
realm,
json.dumps(updated, sort_keys=True),
)
async def domain_delete(request: Request, inputs: dict[str, Any]) -> None:
realm = str(values(inputs)["realm"])
if realm in _BUILTIN_REALMS:
raise ApiError(400, "builtin authentication server can't be removed")
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)",
realm,
)
if not exists:
raise ApiError(404, "realm does not exist")
in_use = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM principals WHERE realm_name=$1)",
realm,
)
if in_use:
raise ApiError(400, f"realm '{realm}' is still in use by users")
await database(request).pool.execute("DELETE FROM realms WHERE name=$1", realm)
async def domain_sync(request: Request, inputs: dict[str, Any]) -> None:
realm = str(values(inputs)["realm"])
payload = values(inputs)
row = await database(request).pool.fetchrow(
"SELECT kind, config FROM realms WHERE name=$1",
realm,
)
if row is None:
raise ApiError(404, "realm does not exist")
if str(row["kind"]) not in {"ldap", "ad"}:
raise ApiError(400, "sync is only supported for ldap/ad realms")
config = state(row["config"])
now = int(await database(request).pool.fetchval("SELECT extract(epoch from now())::bigint"))
config["last_sync"] = now
config["last_sync_options"] = {
key: payload[key]
for key in (
"dry-run",
"enable-new",
"full",
"purge",
"remove-vanished",
"scope",
)
if key in payload
}
await database(request).pool.execute(
"UPDATE realms SET config=$2::jsonb WHERE name=$1",
realm,
json.dumps(config, sort_keys=True),
)
registry.register("/access", "GET", access_index)
registry.register("/access/users", "GET", user_list)
registry.register("/access/users", "POST", user_create)
registry.register("/access/users/{userid}", "GET", user_get)
registry.register("/access/users/{userid}", "PUT", user_update)
registry.register("/access/users/{userid}", "DELETE", user_delete)
registry.register("/access/groups", "GET", group_list)
registry.register("/access/groups", "POST", group_create)
registry.register("/access/groups/{groupid}", "GET", group_get)
registry.register("/access/groups/{groupid}", "PUT", group_update)
registry.register("/access/groups/{groupid}", "DELETE", group_delete)
registry.register("/access/password", "PUT", password_update)
registry.register("/access/acl", "GET", acl_list)
registry.register("/access/acl", "PUT", acl_update)
registry.register("/access/roles", "GET", role_list)
registry.register("/access/roles", "POST", role_create)
registry.register("/access/roles/{roleid}", "GET", role_get)
registry.register("/access/roles/{roleid}", "PUT", role_update)
registry.register("/access/roles/{roleid}", "DELETE", role_delete)
registry.register("/access/domains", "GET", domain_list)
registry.register("/access/domains", "POST", domain_create)
registry.register("/access/domains/{realm}", "GET", domain_get)
registry.register("/access/domains/{realm}", "PUT", domain_update)
registry.register("/access/domains/{realm}", "DELETE", domain_delete)
registry.register("/access/domains/{realm}/sync", "POST", domain_sync)
registry.register("/access/users/{userid}/token", "GET", token_list)
registry.register("/access/users/{userid}/token/{tokenid}", "GET", token_get)
registry.register("/access/users/{userid}/token/{tokenid}", "POST", token_create)
registry.register("/access/users/{userid}/token/{tokenid}", "PUT", token_update)
registry.register("/access/users/{userid}/token/{tokenid}", "DELETE", token_delete)
register_access_auth_handlers(registry)
+381
View File
@@ -0,0 +1,381 @@
"""Access TFA, OpenID, permissions, and ticket helpers with durable state."""
from __future__ import annotations
import json
import secrets
from typing import Any, cast
from urllib.parse import urlencode
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.handlers.common import database, values
from app.security.auth import AuthenticationError, csrf_token, issue_ticket, verify_ticket
_TFA_TYPES = frozenset({"totp", "u2f", "webauthn", "recovery", "yubico"})
def _settings(request: Request) -> Settings:
return cast(Settings, request.app.state.settings)
def _tfa_public(row: Any) -> dict[str, Any]:
created = row["created_at"]
created_ts = int(created.timestamp()) if hasattr(created, "timestamp") else int(created or 0)
return {
"id": str(row["entry_id"]),
"type": str(row["tfa_type"]),
"description": row["description"] or "",
"enable": int(bool(row["enable"])),
"created": created_ts,
}
async def _principal_row(request: Request, userid: str) -> Any:
row = await database(request).pool.fetchrow(
"""SELECT id, name, tfa_locked_until, totp_locked
FROM principals WHERE name=$1""",
userid,
)
if row is None:
raise ApiError(404, "user does not exist")
return row
def register_access_auth_handlers(registry: HandlerRegistry) -> None:
async def ticket_get(_request: Request, _inputs: dict[str, Any]) -> None:
return None
async def permissions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
userid = str(payload.get("userid") or request.state.principal)
path_filter = payload.get("path")
if userid == "root@pam":
caps = {
"/": {
"Permissions.Modify": 1,
"Sys.Audit": 1,
"Sys.Modify": 1,
"VM.Allocate": 1,
"VM.Audit": 1,
"VM.PowerMgmt": 1,
"Datastore.Allocate": 1,
"Datastore.Audit": 1,
}
}
if path_filter:
return {str(path_filter): caps["/"]}
return caps
rows = await database(request).pool.fetch(
"""SELECT a.path, r.privileges
FROM acl_entries a
JOIN principals p ON p.id=a.principal_id
JOIN roles r ON r.name=a.role_name
WHERE p.name=$1
UNION ALL
SELECT a.path, r.privileges
FROM group_acl_entries a
JOIN identity_groups g ON g.id=a.group_id
JOIN identity_group_members m ON m.group_id=g.id
JOIN principals p ON p.id=m.principal_id
JOIN roles r ON r.name=a.role_name
WHERE p.name=$1""",
userid,
)
result: dict[str, dict[str, int]] = {}
for row in rows:
path = str(row["path"])
bucket = result.setdefault(path, {})
for privilege in row["privileges"] or []:
bucket[str(privilege)] = 1
if path_filter:
target = str(path_filter)
merged: dict[str, int] = {}
for path, privs in result.items():
if target == path or target.startswith(path.rstrip("/") + "/") or path == "/":
merged.update(privs)
return {target: merged} if merged else {}
return result
async def vncticket(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
ticket = str(payload["vncticket"])
key = _settings(request).ticket_signing_key.get_secret_value().encode()
try:
claims = verify_ticket(ticket, key)
except AuthenticationError as error:
raise ApiError(401, "authentication failure") from error
authid = str(payload["authid"])
if claims.principal != authid:
raise ApiError(401, "authentication failure")
return None
async def openid_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return [{"subdir": "auth-url"}, {"subdir": "login"}]
async def openid_auth_url(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
realm = str(payload["realm"])
redirect_url = str(payload["redirect-url"])
row = await database(request).pool.fetchrow(
"SELECT name, kind, config FROM realms WHERE name=$1",
realm,
)
if row is None:
raise ApiError(404, "realm does not exist")
if str(row["kind"]) != "openid":
raise ApiError(400, "realm is not an OpenID realm")
state = secrets.token_urlsafe(16)
await database(request).pool.execute(
"""INSERT INTO openid_pending(state, realm, redirect_url)
VALUES($1, $2, $3)
ON CONFLICT (state) DO UPDATE
SET realm=EXCLUDED.realm, redirect_url=EXCLUDED.redirect_url,
created_at=now()""",
state,
realm,
redirect_url,
)
config = row["config"]
if isinstance(config, str):
config = json.loads(config)
config = config or {}
issuer = str(config.get("issuer-url") or "https://openid.example.local")
client_id = str(config.get("client-id") or "pve-simulator")
query = urlencode(
{
"client_id": client_id,
"redirect_uri": redirect_url,
"response_type": "code",
"scope": str(config.get("scopes") or "openid email profile"),
"state": state,
}
)
return f"{issuer.rstrip('/')}/authorize?{query}"
async def openid_login(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
state = str(payload["state"])
pending = await database(request).pool.fetchrow(
"SELECT realm, redirect_url FROM openid_pending WHERE state=$1",
state,
)
if pending is None:
raise ApiError(400, "invalid OpenID state")
redirect = payload.get("redirect-url")
if redirect is not None and str(redirect) != str(pending["redirect_url"]):
raise ApiError(400, "redirect-url mismatch")
realm = str(pending["realm"])
code = str(payload["code"])
username = f"openid-{code[:12]}@{realm}"
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)",
username,
)
if not exists:
await database(request).pool.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES(gen_random_uuid(), $1, NULL, $2)""",
username,
realm,
)
await database(request).pool.execute(
"DELETE FROM openid_pending WHERE state=$1",
state,
)
key = _settings(request).ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(username, key)
return {
"username": username,
"ticket": ticket,
"CSRFPreventionToken": csrf_token(ticket, key),
"clustername": "pve-simulator",
"cap": {},
}
async def tfa_list_all(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(request).pool.fetch(
"""SELECT p.name AS userid, p.tfa_locked_until, p.totp_locked,
t.entry_id, t.tfa_type, t.description, t.enable, t.created_at
FROM principals p
LEFT JOIN tfa_entries t ON t.principal_id=p.id
ORDER BY p.name, t.entry_id"""
)
by_user: dict[str, dict[str, Any]] = {}
for row in rows:
userid = str(row["userid"])
item = by_user.setdefault(
userid,
{
"userid": userid,
"entries": [],
"totp-locked": int(bool(row["totp_locked"])),
},
)
if row["tfa_locked_until"] is not None:
locked = row["tfa_locked_until"]
item["tfa-locked-until"] = (
int(locked.timestamp()) if hasattr(locked, "timestamp") else int(locked)
)
if row["entry_id"] is not None:
item["entries"].append(_tfa_public(row))
return list(by_user.values())
async def tfa_list_user(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
userid = str(values(inputs)["userid"])
principal = await _principal_row(request, userid)
rows = await database(request).pool.fetch(
"""SELECT entry_id, tfa_type, description, enable, created_at
FROM tfa_entries WHERE principal_id=$1 ORDER BY entry_id""",
principal["id"],
)
return [_tfa_public(row) for row in rows]
async def tfa_add(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
userid = payload.get("userid")
if userid in {None, ""}:
raise ApiError(400, "parameter 'userid' is required")
userid = str(userid)
tfa_type = payload.get("type")
if tfa_type in {None, ""}:
raise ApiError(400, "parameter 'type' is required")
tfa_type = str(tfa_type)
if tfa_type not in _TFA_TYPES:
raise ApiError(400, f"invalid TFA type: {tfa_type}")
principal = await _principal_row(request, userid)
entry_id = secrets.token_hex(8)
secret = str(payload.get("value") or payload.get("totp") or secrets.token_hex(20))
description = str(payload.get("description") or tfa_type)
recovery: list[str] = []
metadata: dict[str, Any] = {}
if tfa_type == "recovery":
recovery = [secrets.token_hex(5) for _ in range(8)]
metadata["recovery"] = recovery
await database(request).pool.execute(
"""INSERT INTO tfa_entries(
principal_id, entry_id, tfa_type, description, enable, secret, metadata
) VALUES($1, $2, $3, $4, true, $5, $6::jsonb)""",
principal["id"],
entry_id,
tfa_type,
description,
secret,
json.dumps(metadata, sort_keys=True),
)
result: dict[str, Any] = {"id": entry_id}
if recovery:
result["recovery"] = recovery
if payload.get("challenge") is not None:
result["challenge"] = payload.get("challenge")
return result
async def tfa_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
userid = str(values(inputs)["userid"])
entry_id = str(values(inputs)["id"])
principal = await _principal_row(request, userid)
row = await database(request).pool.fetchrow(
"""SELECT entry_id, tfa_type, description, enable, created_at
FROM tfa_entries WHERE principal_id=$1 AND entry_id=$2""",
principal["id"],
entry_id,
)
if row is None:
raise ApiError(404, "TFA entry does not exist")
return _tfa_public(row)
async def tfa_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
userid = payload.get("userid")
if userid in {None, ""}:
raise ApiError(400, "parameter 'userid' is required")
userid = str(userid)
entry_id = payload.get("id")
if entry_id in {None, ""}:
raise ApiError(400, "parameter 'id' is required")
entry_id = str(entry_id)
provided = frozenset(str(item) for item in inputs.get("provided", payload))
principal = await _principal_row(request, userid)
row = await database(request).pool.fetchrow(
"SELECT entry_id FROM tfa_entries WHERE principal_id=$1 AND entry_id=$2",
principal["id"],
entry_id,
)
if row is None:
raise ApiError(404, "TFA entry does not exist")
if "description" in provided:
await database(request).pool.execute(
"""UPDATE tfa_entries SET description=$3
WHERE principal_id=$1 AND entry_id=$2""",
principal["id"],
entry_id,
payload.get("description"),
)
if "enable" in provided:
enabled = payload.get("enable")
if isinstance(enabled, bool):
value = enabled
else:
value = str(enabled).lower() in {"1", "true", "yes", "on"}
await database(request).pool.execute(
"""UPDATE tfa_entries SET enable=$3
WHERE principal_id=$1 AND entry_id=$2""",
principal["id"],
entry_id,
value,
)
async def tfa_delete(request: Request, inputs: dict[str, Any]) -> None:
userid = str(values(inputs)["userid"])
entry_id = str(values(inputs)["id"])
principal = await _principal_row(request, userid)
status = await database(request).pool.execute(
"DELETE FROM tfa_entries WHERE principal_id=$1 AND entry_id=$2",
principal["id"],
entry_id,
)
if status != "DELETE 1":
raise ApiError(404, "TFA entry does not exist")
async def user_tfa_types(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
userid = str(values(inputs)["userid"])
principal = await _principal_row(request, userid)
rows = await database(request).pool.fetch(
"""SELECT DISTINCT tfa_type FROM tfa_entries
WHERE principal_id=$1 AND enable=true ORDER BY tfa_type""",
principal["id"],
)
types = [str(row["tfa_type"]) for row in rows]
realm = userid.split("@", 1)[1] if "@" in userid else "pam"
return {"user": types, "types": types, "realm": realm}
async def unlock_tfa(request: Request, inputs: dict[str, Any]) -> bool:
userid = str(values(inputs)["userid"])
status = await database(request).pool.execute(
"""UPDATE principals
SET tfa_locked_until=NULL, totp_locked=false
WHERE name=$1""",
userid,
)
if status != "UPDATE 1":
raise ApiError(404, "user does not exist")
return True
registry.register("/access/ticket", "GET", ticket_get)
registry.register("/access/permissions", "GET", permissions)
registry.register("/access/vncticket", "POST", vncticket)
registry.register("/access/openid", "GET", openid_index)
registry.register("/access/openid/auth-url", "POST", openid_auth_url)
registry.register("/access/openid/login", "POST", openid_login)
registry.register("/access/tfa", "GET", tfa_list_all)
registry.register("/access/tfa/{userid}", "GET", tfa_list_user)
registry.register("/access/tfa/{userid}", "POST", tfa_add)
registry.register("/access/tfa/{userid}/{id}", "GET", tfa_get)
registry.register("/access/tfa/{userid}/{id}", "PUT", tfa_update)
registry.register("/access/tfa/{userid}/{id}", "DELETE", tfa_delete)
registry.register("/access/users/{userid}/tfa", "GET", user_tfa_types)
registry.register("/access/users/{userid}/unlock-tfa", "PUT", unlock_tfa)
+223
View File
@@ -0,0 +1,223 @@
"""Cluster ACME accounts and DNS plugins."""
from __future__ import annotations
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
_DEFAULT_DIRECTORIES = [
{
"name": "Let's Encrypt V2",
"url": "https://acme-v02.api.letsencrypt.org/directory",
},
{
"name": "Let's Encrypt V2 Staging",
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
},
]
_CHALLENGE_SCHEMA = [
{
"id": "dns",
"name": "DNS plugin",
"type": "dns",
"fields": [{"name": "api", "type": "string"}],
}
]
def _acme(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"acme",
{"accounts": {}, "plugins": {}, "meta": {}},
)
if not isinstance(current, dict):
current = {"accounts": {}, "plugins": {}, "meta": {}}
metadata["acme"] = current
current.setdefault("accounts", {})
current.setdefault("plugins", {})
current.setdefault("meta", {})
return current
def register_acme_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs(
"account",
"challenge-schema",
"directories",
"meta",
"plugins",
"tos",
)
async def account_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
accounts = _acme(metadata)["accounts"]
return [
{
"name": name,
"contact": item.get("contact", []),
"directory": item.get("directory"),
}
for name, item in sorted(accounts.items())
]
async def account_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload.get("name") or "default")
metadata = await cluster_metadata(request)
accounts = _acme(metadata)["accounts"]
if name in accounts:
raise ApiError(400, f"ACME account '{name}' already exists")
accounts[name] = {
"name": name,
"contact": payload.get("contact"),
"directory": payload.get("directory") or _DEFAULT_DIRECTORIES[0]["url"],
"tos_url": payload.get("tos_url"),
"eab-kid": payload.get("eab-kid"),
# eab-hmac-key stored but never returned
"eab-hmac-key": payload.get("eab-hmac-key"),
"location": f"https://acme.example.local/acct/{name}",
}
await save_cluster_metadata(request, metadata)
async def account_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
account = _acme(metadata)["accounts"].get(name)
if not isinstance(account, dict):
raise ApiError(404, "ACME account does not exist")
return {
"name": name,
"contact": account.get("contact"),
"directory": account.get("directory"),
"tos": account.get("tos_url"),
"location": account.get("location"),
}
async def account_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
accounts = _acme(metadata)["accounts"]
if name not in accounts:
raise ApiError(404, "ACME account does not exist")
if "contact" in payload:
accounts[name]["contact"] = payload["contact"]
await save_cluster_metadata(request, metadata)
async def account_delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
accounts = _acme(metadata)["accounts"]
if name not in accounts:
raise ApiError(404, "ACME account does not exist")
del accounts[name]
await save_cluster_metadata(request, metadata)
async def plugins_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
plugins = _acme(metadata)["plugins"]
plugin_type = values(inputs).get("type")
result = []
for plugin_id, item in sorted(plugins.items()):
if plugin_type and item.get("type") != plugin_type:
continue
result.append({"plugin": plugin_id, **{k: v for k, v in item.items() if k != "data"}})
return result
async def plugins_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
plugin_id = str(payload["id"])
metadata = await cluster_metadata(request)
plugins = _acme(metadata)["plugins"]
if plugin_id in plugins:
raise ApiError(400, f"ACME plugin '{plugin_id}' already exists")
plugins[plugin_id] = {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
}
await save_cluster_metadata(request, metadata)
async def plugins_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
plugin_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
plugin = _acme(metadata)["plugins"].get(plugin_id)
if not isinstance(plugin, dict):
raise ApiError(404, "ACME plugin does not exist")
return {"id": plugin_id, **plugin}
async def plugins_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
plugin_id = str(payload["id"])
metadata = await cluster_metadata(request)
plugins = _acme(metadata)["plugins"]
if plugin_id not in plugins:
raise ApiError(404, "ACME plugin does not exist")
current = dict(plugins[plugin_id])
for key in [
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
]:
current.pop(key, None)
for key, value in payload.items():
if key in {"id", "delete", "digest"}:
continue
current[key] = value
current["id"] = plugin_id
plugins[plugin_id] = current
await save_cluster_metadata(request, metadata)
async def plugins_delete(request: Request, inputs: dict[str, Any]) -> None:
plugin_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
plugins = _acme(metadata)["plugins"]
if plugin_id not in plugins:
raise ApiError(404, "ACME plugin does not exist")
del plugins[plugin_id]
await save_cluster_metadata(request, metadata)
async def directories(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(_DEFAULT_DIRECTORIES)
async def challenge_schema(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(_CHALLENGE_SCHEMA)
async def meta(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
metadata = await cluster_metadata(request)
meta_store = _acme(metadata).setdefault("meta", {})
payload = meta_store.setdefault(
directory,
{
"termsOfService": f"{directory.rstrip('/')}/tos",
"caaIdentities": ["letsencrypt.org"],
},
)
await save_cluster_metadata(request, metadata)
return dict(payload)
async def tos(request: Request, inputs: dict[str, Any]) -> str:
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
result = await meta(request, {"values": {"directory": directory}, "provided": frozenset()})
return str(result.get("termsOfService") or "")
registry.register("/cluster/acme", "GET", index)
registry.register("/cluster/acme/account", "GET", account_list)
registry.register("/cluster/acme/account", "POST", account_create)
registry.register("/cluster/acme/account/{name}", "GET", account_get)
registry.register("/cluster/acme/account/{name}", "PUT", account_update)
registry.register("/cluster/acme/account/{name}", "DELETE", account_delete)
registry.register("/cluster/acme/plugins", "GET", plugins_list)
registry.register("/cluster/acme/plugins", "POST", plugins_create)
registry.register("/cluster/acme/plugins/{id}", "GET", plugins_get)
registry.register("/cluster/acme/plugins/{id}", "PUT", plugins_update)
registry.register("/cluster/acme/plugins/{id}", "DELETE", plugins_delete)
registry.register("/cluster/acme/directories", "GET", directories)
registry.register("/cluster/acme/challenge-schema", "GET", challenge_schema)
registry.register("/cluster/acme/meta", "GET", meta)
registry.register("/cluster/acme/tos", "GET", tos)
+236
View File
@@ -0,0 +1,236 @@
"""Cluster backup and vzdump handlers."""
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.primitives import ConflictError
from app.handlers.common import database, require_node, state, values
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
def register_backup_handlers(registry: HandlerRegistry) -> None:
async def backup_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
r.external_id AS vmid, n.name AS node, s.storage_id
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
LEFT JOIN nodes n ON n.id = r.node_id
JOIN storages s ON s.resource_id = b.storage_resource_id
ORDER BY b.created_at DESC LIMIT 2000"""
)
result: list[dict[str, Any]] = []
for row in rows:
metadata = state(row["metadata"])
result.append(
{
"id": str(row["id"]),
"volid": str(row["volume_id"]),
"size": int(row["size_bytes"]),
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
"node": str(row["node"]) if row["node"] is not None else None,
"storage": str(row["storage_id"]),
"starttime": int(row["created_at"].timestamp()),
"mode": metadata.get("mode", "snapshot"),
"type": metadata.get("type", "vzdump"),
}
)
return result
async def backup_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
backup_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow(
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
r.external_id AS vmid, n.name AS node, s.storage_id
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
LEFT JOIN nodes n ON n.id = r.node_id
JOIN storages s ON s.resource_id = b.storage_resource_id
WHERE b.id::text = $1 OR b.volume_id = $1""",
backup_id,
)
if row is None:
raise ApiError(404, "backup does not exist")
metadata = state(row["metadata"])
return {
"id": str(row["id"]),
"volid": str(row["volume_id"]),
"size": int(row["size_bytes"]),
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
"node": str(row["node"]) if row["node"] is not None else None,
"storage": str(row["storage_id"]),
"starttime": int(row["created_at"].timestamp()),
"notes": metadata.get("notes-template"),
**metadata,
}
async def backup_update(request: Request, inputs: dict[str, Any]) -> None:
backup_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow(
"SELECT id, metadata FROM backups WHERE id::text = $1",
backup_id,
)
if row is None:
raise ApiError(404, "backup does not exist")
metadata = state(row["metadata"])
payload = values(inputs)
if "notes" in payload:
metadata["notes-template"] = payload["notes"]
await database(request).pool.execute(
"UPDATE backups SET metadata=$2::jsonb WHERE id=$1",
row["id"],
json.dumps(metadata, sort_keys=True),
)
async def backup_delete(request: Request, inputs: dict[str, Any]) -> None:
backup_id = str(values(inputs)["id"])
status = await database(request).pool.execute(
"DELETE FROM backups WHERE id::text = $1",
backup_id,
)
if status != "DELETE 1":
raise ApiError(404, "backup does not exist")
async def backup_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload.get("node") or payload.get("target") or "pve01")
await require_node(request, node)
vmid = payload.get("vmid")
return await _schedule_vzdump(
request,
node=node,
vmids=[str(vmid)] if vmid is not None else None,
payload=payload,
)
async def backup_info(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT r.external_id AS vmid, n.name AS node, max(b.created_at) AS last_backup
FROM resources r
JOIN nodes n ON n.id = r.node_id
LEFT JOIN backups b ON b.resource_id = r.id
WHERE r.kind = 'qemu'
GROUP BY r.external_id, n.name
ORDER BY r.external_id::integer
LIMIT 5000"""
)
return [
{
"vmid": int(row["vmid"]),
"node": str(row["node"]),
"lastbackup": int(row["last_backup"].timestamp()) if row["last_backup"] else 0,
"protected": 0,
}
for row in rows
]
async def backup_not_backed_up(_request: Request, _inputs: dict[str, Any]) -> list[int]:
rows = await database(_request).pool.fetch(
"""SELECT r.external_id::integer AS vmid
FROM resources r
LEFT JOIN backups b ON b.resource_id = r.id
WHERE r.kind = 'qemu' AND b.id IS NULL
ORDER BY r.external_id::integer"""
)
return [int(row["vmid"]) for row in rows]
async def backup_included_volumes(request: Request, inputs: dict[str, Any]) -> list[str]:
backup_id = str(values(inputs)["id"])
row = await database(request).pool.fetchrow(
"""SELECT b.volume_id, r.external_id AS vmid
FROM backups b LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.id::text = $1""",
backup_id,
)
if row is None:
raise ApiError(404, "backup does not exist")
vmid = row["vmid"]
return [f"qemu/{vmid}"] if vmid is not None else [str(row["volume_id"])]
async def vzdump_defaults(_request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(_request, str(values(inputs)["node"]))
return {
"all": 0,
"bwlimit": 0,
"compress": "zstd",
"dumpdir": "backup",
"mode": "snapshot",
"remove": 0,
"storage": "nfs-backup",
"mailto": "",
"notes-template": "{{guestname}}",
}
async def vzdump_extractconfig(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
volid = str(payload.get("volume") or payload.get("volid") or "")
if not volid:
raise ApiError(400, "volume parameter required")
return f"# simulated vzdump config extracted from {volid}\name: demo\nmemory: 2048\n"
async def vzdump_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
vmids = payload.get("vmid")
selected = None
if vmids is not None:
selected = [str(item) for item in str(vmids).split(",") if item.strip()]
return await _schedule_vzdump(request, node=node, vmids=selected, payload=payload)
registry.register("/cluster/backup", "GET", backup_list)
registry.register("/cluster/backup", "POST", backup_create)
registry.register("/cluster/backup-info", "GET", backup_info)
registry.register("/cluster/backup-info/not-backed-up", "GET", backup_not_backed_up)
registry.register("/cluster/backup/{id}", "GET", backup_get)
registry.register("/cluster/backup/{id}", "PUT", backup_update)
registry.register("/cluster/backup/{id}", "DELETE", backup_delete)
registry.register("/cluster/backup/{id}/included_volumes", "GET", backup_included_volumes)
registry.register("/nodes/{node}/vzdump", "POST", vzdump_create)
registry.register("/nodes/{node}/vzdump/defaults", "GET", vzdump_defaults)
registry.register("/nodes/{node}/vzdump/extractconfig", "GET", vzdump_extractconfig)
async def _schedule_vzdump(
request: Request,
*,
node: str,
vmids: list[str] | None,
payload: dict[str, Any],
) -> str:
pool = database(request).pool
if vmids is None:
rows = await pool.fetch(
"""SELECT external_id FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='qemu' ORDER BY external_id::integer LIMIT 100""",
node,
)
vmids = [str(row["external_id"]) for row in rows]
if not vmids:
raise ApiError(400, "no virtual machines selected for backup")
vmid = vmids[0]
upid = str(Upid.allocate(node, "vzdump", vmid, str(request.state.principal)))
try:
task = await TaskRepository(pool).create(
upid=upid,
task_type="vzdump",
payload={
"node": node,
"vmids": vmids,
"storage": str(payload.get("storage") or "nfs-backup"),
"mode": str(payload.get("mode") or "snapshot"),
"compress": str(payload.get("compress") or "zstd"),
},
resource_key=f"backup:{node}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
+759
View File
@@ -0,0 +1,759 @@
"""Ceph semantic handlers with durable cluster/node state."""
from __future__ import annotations
import json
import secrets
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
database,
node_metadata,
require_node,
save_node_metadata,
state,
subdirs,
values,
)
from app.simulation.seed import CLUSTER_ID
DEFAULT_CLUSTER_CEPH = {
"initialized": True,
"config": {
"network": "10.10.10.0/24",
"cluster-network": "10.10.10.0/24",
"size": 3,
"min_size": 2,
"pg_bits": 7,
},
"cfg_db": [
{"section": "global", "name": "auth_client_required", "value": "cephx"},
{"section": "global", "name": "fsid", "value": "pve-simulator-fsid"},
],
"cfg_raw": "[global]\nfsid = pve-simulator-fsid\nauth_client_required = cephx\n",
"cfg_values": {},
"pools": {
"rbd": {
"pool": "rbd",
"size": 3,
"min_size": 2,
"pg_num": 128,
"application": "rbd",
"crush_rule": "replicated_rule",
}
},
"fs": {},
"rules": [{"name": "replicated_rule", "id": 0}],
"crush": "device 0 osd.0 class hdd\n",
"running": True,
}
async def _load_cluster_ceph(request: Request) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"SELECT metadata FROM clusters WHERE id=$1",
CLUSTER_ID,
)
metadata = state(row["metadata"]) if row is not None else {}
ceph = metadata.get("ceph")
if not isinstance(ceph, dict) or not ceph:
return dict(DEFAULT_CLUSTER_CEPH)
merged = dict(DEFAULT_CLUSTER_CEPH)
merged.update(ceph)
for key in ("config", "pools", "fs", "cfg_values"):
if not isinstance(merged.get(key), dict):
merged[key] = dict(cast(dict[str, Any], DEFAULT_CLUSTER_CEPH[key]))
return merged
async def _save_cluster_ceph(request: Request, ceph: dict[str, Any]) -> None:
await database(request).pool.execute(
"""UPDATE clusters SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb), '{ceph}', $2::jsonb, true
), updated_at=now() WHERE id=$1""",
CLUSTER_ID,
json.dumps(ceph, sort_keys=True),
)
async def _load_node_ceph(request: Request, node: str) -> dict[str, Any]:
metadata = await node_metadata(request, node)
ops = metadata.setdefault("ops", {})
ceph = ops.setdefault(
"ceph",
{
"mds": {},
"mgr": {},
"mon": {f"{node}": {"name": node, "addr": f"{node}.local:6789", "rank": 0}},
"log": [{"t": 1_700_000_000, "n": 0, "line": "ceph simulator ready"}],
},
)
if not isinstance(ceph, dict):
ceph = {
"mds": {},
"mgr": {},
"mon": {},
"log": [],
}
ops["ceph"] = ceph
ceph.setdefault("mds", {})
ceph.setdefault("mgr", {})
ceph.setdefault("mon", {})
ceph.setdefault("log", [])
return ceph
async def _save_node_ceph(request: Request, node: str, ceph: dict[str, Any]) -> None:
metadata = await node_metadata(request, node)
ops = metadata.setdefault("ops", {})
ops["ceph"] = ceph
await save_node_metadata(request, node, metadata)
def _upid(node: str, kind: str) -> str:
return f"UPID:{node}:{secrets.token_hex(4)}:{kind}:root@pam:"
async def _osd_row(request: Request, node: str, osdid: str) -> Any:
row = await database(request).pool.fetchrow(
"""SELECT r.id, r.external_id, r.state
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='ceph-osd'
AND (r.external_id=$2 OR r.external_id=$3)""",
node,
osdid,
f"osd.{osdid}",
)
if row is None:
raise ApiError(404, "OSD does not exist")
return row
def register_ceph_handlers(registry: HandlerRegistry) -> None:
async def ceph_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(values(inputs)["node"])
await require_node(request, node)
return subdirs(
"cfg",
"cmd-safety",
"crush",
"fs",
"init",
"log",
"mds",
"mgr",
"mon",
"osd",
"pool",
"restart",
"rules",
"start",
"status",
"stop",
)
async def cfg_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("db", "raw", "value")
async def cfg_db(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return list(ceph.get("cfg_db") or [])
async def cfg_raw(request: Request, inputs: dict[str, Any]) -> str:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return str(ceph.get("cfg_raw") or "")
async def cfg_value(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
ceph = await _load_cluster_ceph(request)
keys = [item.strip() for item in str(payload.get("config-keys") or "").split(",") if item]
stored = ceph.setdefault("cfg_values", {})
result = {key: stored.get(key, "") for key in keys} if keys else dict(stored)
await _save_cluster_ceph(request, ceph)
return result
async def crush(request: Request, inputs: dict[str, Any]) -> str:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return str(ceph.get("crush") or "")
async def rules(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return list(ceph.get("rules") or [])
async def log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ceph = await _load_node_ceph(request, node)
entries = list(ceph.get("log") or [])
start = int(values(inputs).get("start") or 0)
limit = int(values(inputs).get("limit") or 50)
return entries[start : start + limit]
async def cmd_safety(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
return {
"safe": 1,
"action": payload.get("action"),
"service": payload.get("service"),
"id": payload.get("id"),
}
async def init(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ceph = await _load_cluster_ceph(request)
config = ceph.setdefault("config", {})
for key in (
"network",
"cluster-network",
"size",
"min_size",
"pg_bits",
"disable_cephx",
):
if key in payload:
config[key] = payload[key]
ceph["initialized"] = True
await _save_cluster_ceph(request, ceph)
node_ceph = await _load_node_ceph(request, node)
node_ceph.setdefault("mon", {})[node] = {
"name": node,
"addr": f"{node}.local:6789",
"rank": 0,
}
await _save_node_ceph(request, node, node_ceph)
async def status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
return await cluster_ceph_status(request, inputs)
async def start(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ceph = await _load_cluster_ceph(request)
ceph["running"] = True
ceph["last_service_action"] = {
"action": "start",
"service": payload.get("service"),
"node": node,
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephstart")
async def stop(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ceph = await _load_cluster_ceph(request)
ceph["running"] = False
ceph["last_service_action"] = {
"action": "stop",
"service": payload.get("service"),
"node": node,
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephstop")
async def restart(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ceph = await _load_cluster_ceph(request)
ceph["running"] = True
ceph["last_service_action"] = {
"action": "restart",
"service": payload.get("service"),
"node": node,
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephrestart")
async def pool_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
pools = ceph.get("pools") or {}
return [dict(item) for _, item in sorted(pools.items()) if isinstance(item, dict)]
async def pool_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pools = ceph.setdefault("pools", {})
if name in pools:
raise ApiError(400, f"pool '{name}' already exists")
pools[name] = {
"pool": name,
"size": int(payload.get("size") or 3),
"min_size": int(payload.get("min_size") or 2),
"pg_num": int(payload.get("pg_num") or 128),
"application": str(payload.get("application") or "rbd"),
"crush_rule": str(payload.get("crush_rule") or "replicated_rule"),
"pg_autoscale_mode": payload.get("pg_autoscale_mode", "warn"),
"target_size": payload.get("target_size"),
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephcreatepool")
async def pool_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pool = (ceph.get("pools") or {}).get(name)
if not isinstance(pool, dict):
raise ApiError(404, "pool does not exist")
return [dict(pool)]
async def pool_update(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pools = ceph.setdefault("pools", {})
if name not in pools or not isinstance(pools[name], dict):
raise ApiError(404, "pool does not exist")
current = dict(pools[name])
for key in (
"application",
"crush_rule",
"min_size",
"pg_autoscale_mode",
"pg_num",
"pg_num_min",
"size",
"target_size",
"target_size_ratio",
):
if key in payload:
current[key] = payload[key]
pools[name] = current
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephsetpool")
async def pool_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pools = ceph.setdefault("pools", {})
if name not in pools:
raise ApiError(404, "pool does not exist")
del pools[name]
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephdestroypool")
async def pool_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
pool = (ceph.get("pools") or {}).get(name)
if not isinstance(pool, dict):
raise ApiError(404, "pool does not exist")
return {
**pool,
"pg_num": pool.get("pg_num", 128),
"bytes_used": 0,
"percent_used": 0.0,
"healthy": True,
}
async def fs_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
ceph = await _load_cluster_ceph(request)
return [dict(item) for _, item in sorted((ceph.get("fs") or {}).items())]
async def fs_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
filesystems = ceph.setdefault("fs", {})
if name in filesystems:
raise ApiError(400, f"fs '{name}' already exists")
filesystems[name] = {
"name": name,
"metadata": f"{name}_meta",
"data": f"{name}_data",
"pg_num": int(payload.get("pg_num") or 32),
}
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephcreatefs")
async def fs_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_cluster_ceph(request)
filesystems = ceph.setdefault("fs", {})
if name not in filesystems:
raise ApiError(404, "fs does not exist")
del filesystems[name]
await _save_cluster_ceph(request, ceph)
return _upid(node, "cephdestroyfs")
async def mds_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ceph = await _load_node_ceph(request, node)
return [
{"name": name, **data}
for name, data in sorted((ceph.get("mds") or {}).items())
if isinstance(data, dict)
]
async def mds_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_node_ceph(request, node)
mds = ceph.setdefault("mds", {})
if name in mds:
raise ApiError(400, f"mds '{name}' already exists")
mds[name] = {
"name": name,
"state": "up:active",
"hotstandby": int(bool(payload.get("hotstandby"))),
}
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephcreatemds")
async def mds_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(payload["name"])
ceph = await _load_node_ceph(request, node)
mds = ceph.setdefault("mds", {})
if name not in mds:
raise ApiError(404, "mds does not exist")
del mds[name]
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephdestroymds")
async def mgr_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ceph = await _load_node_ceph(request, node)
return [
{"name": name, **data}
for name, data in sorted((ceph.get("mgr") or {}).items())
if isinstance(data, dict)
]
async def mgr_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
mgr_id = str(payload["id"])
ceph = await _load_node_ceph(request, node)
mgr = ceph.setdefault("mgr", {})
if mgr_id in mgr:
raise ApiError(400, f"mgr '{mgr_id}' already exists")
mgr[mgr_id] = {"name": mgr_id, "state": "active"}
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephcreatemgr")
async def mgr_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
mgr_id = str(payload["id"])
ceph = await _load_node_ceph(request, node)
mgr = ceph.setdefault("mgr", {})
if mgr_id not in mgr:
raise ApiError(404, "mgr does not exist")
del mgr[mgr_id]
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephdestroymgr")
async def mon_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ceph = await _load_node_ceph(request, node)
return [
{"name": name, **data}
for name, data in sorted((ceph.get("mon") or {}).items())
if isinstance(data, dict)
]
async def mon_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
monid = str(payload["monid"])
ceph = await _load_node_ceph(request, node)
mons = ceph.setdefault("mon", {})
if monid in mons:
raise ApiError(400, f"mon '{monid}' already exists")
mons[monid] = {
"name": monid,
"addr": str(payload.get("mon-address") or f"{node}.local:6789"),
"rank": len(mons),
}
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephcreatemon")
async def mon_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
monid = str(payload["monid"])
ceph = await _load_node_ceph(request, node)
mons = ceph.setdefault("mon", {})
if monid not in mons:
raise ApiError(404, "mon does not exist")
del mons[monid]
await _save_node_ceph(request, node, ceph)
return _upid(node, "cephdestroymon")
async def osd_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
rows = await database(request).pool.fetch(
"""SELECT r.external_id, r.state
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='ceph-osd'
ORDER BY r.external_id""",
node,
)
result: list[dict[str, Any]] = []
for row in rows:
payload = state(row["state"])
osd_id = payload.get("osd_id", row["external_id"])
result.append(
{
"osd": int(osd_id) if str(osd_id).isdigit() else osd_id,
"status": payload.get("status", "up"),
"in": 1 if payload.get("in", True) else 0,
"weight": payload.get("weight", 1.0),
"device_class": payload.get("device_class", "hdd"),
}
)
return result
async def osd_create(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
next_id = await database(request).pool.fetchval(
"""SELECT COALESCE(
MAX(NULLIF(regexp_replace(external_id, '\\D', '', 'g'), '')::int),
-1
) + 1
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='ceph-osd'""",
node,
)
osd_id = int(next_id or 0)
external_id = f"osd.{osd_id}"
node_id = await database(request).pool.fetchval(
"SELECT id FROM nodes WHERE name=$1",
node,
)
osd_state = {
"osd_id": osd_id,
"status": "up",
"in": True,
"weight": 1.0,
"device_class": payload.get("crush-device-class") or "hdd",
"dev": payload.get("dev"),
"size_bytes": 0,
"used_bytes": 0,
}
await database(request).pool.execute(
"""INSERT INTO resources(id, node_id, kind, external_id, state)
VALUES(gen_random_uuid(), $1, 'ceph-osd', $2, $3::jsonb)""",
node_id,
external_id,
json.dumps(osd_state, sort_keys=True),
)
return _upid(node, "cephcreateosd")
async def osd_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
osdid = str(values(inputs)["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
payload = state(row["state"])
return {
"osd": int(osdid) if osdid.isdigit() else osdid,
"status": payload.get("status", "up"),
"in": 1 if payload.get("in", True) else 0,
"weight": payload.get("weight", 1.0),
"size": payload.get("size_bytes", 0),
"used": payload.get("used_bytes", 0),
"device_class": payload.get("device_class", "hdd"),
}
async def osd_delete(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
await database(request).pool.execute("DELETE FROM resources WHERE id=$1", row["id"])
return _upid(node, "cephdestroyosd")
async def osd_in(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
current["in"] = True
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1 WHERE id=$1",
row["id"],
json.dumps(current, sort_keys=True),
)
async def osd_out(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
current["in"] = False
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1 WHERE id=$1",
row["id"],
json.dumps(current, sort_keys=True),
)
async def osd_scrub(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
current["last_scrub"] = {
"deep": int(bool(payload.get("deep"))),
"token": secrets.token_hex(4),
}
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1 WHERE id=$1",
row["id"],
json.dumps(current, sort_keys=True),
)
async def osd_lv_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
return {
"lv_name": f"osd-block-{osdid}",
"lv_path": f"/dev/ceph/{osdid}",
"lv_size": current.get("size_bytes", 0),
"type": payload.get("type") or "block",
}
async def osd_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
osdid = str(payload["osdid"])
await require_node(request, node)
row = await _osd_row(request, node, osdid)
current = state(row["state"])
return {
"osd": {
"id": int(osdid) if osdid.isdigit() else osdid,
"uuid": current.get("uuid") or f"osd-uuid-{osdid}",
"device_class": current.get("device_class", "hdd"),
},
"devices": [{"dev": current.get("dev") or f"/dev/sd{osdid}"}],
}
async def cluster_ceph_status(_request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
row = await database(_request).pool.fetchrow(
"""SELECT capacity_bytes, used_bytes FROM storages
WHERE storage_type='ceph' ORDER BY storage_id LIMIT 1"""
)
total = int(row["capacity_bytes"] or 0) if row is not None else 0
used = int(row["used_bytes"] or 0) if row is not None else 0
osd_count = await database(_request).pool.fetchval(
"SELECT count(*)::int FROM resources WHERE kind='ceph-osd'"
)
ceph = await _load_cluster_ceph(_request)
return {
"version": "17.2.7",
"health": {"status": "HEALTH_OK" if ceph.get("running", True) else "HEALTH_WARN"},
"osdmap": {
"num_osds": osd_count,
"num_up_osds": osd_count - 1,
"num_in_osds": osd_count - 1,
},
"pgmap": {"bytes_used": used, "bytes_total": total},
"fsmap": {"filesystems": list((ceph.get("fs") or {}).keys())},
}
base = "/nodes/{node}/ceph"
registry.register(base, "GET", ceph_index)
registry.register(f"{base}/cfg", "GET", cfg_index)
registry.register(f"{base}/cfg/db", "GET", cfg_db)
registry.register(f"{base}/cfg/raw", "GET", cfg_raw)
registry.register(f"{base}/cfg/value", "GET", cfg_value)
registry.register(f"{base}/cmd-safety", "GET", cmd_safety)
registry.register(f"{base}/crush", "GET", crush)
registry.register(f"{base}/fs", "GET", fs_list)
registry.register(f"{base}/fs/{{name}}", "POST", fs_create)
registry.register(f"{base}/fs/{{name}}", "DELETE", fs_delete)
registry.register(f"{base}/init", "POST", init)
registry.register(f"{base}/log", "GET", log)
registry.register(f"{base}/mds", "GET", mds_list)
registry.register(f"{base}/mds/{{name}}", "POST", mds_create)
registry.register(f"{base}/mds/{{name}}", "DELETE", mds_delete)
registry.register(f"{base}/mgr", "GET", mgr_list)
registry.register(f"{base}/mgr/{{id}}", "POST", mgr_create)
registry.register(f"{base}/mgr/{{id}}", "DELETE", mgr_delete)
registry.register(f"{base}/mon", "GET", mon_list)
registry.register(f"{base}/mon/{{monid}}", "POST", mon_create)
registry.register(f"{base}/mon/{{monid}}", "DELETE", mon_delete)
registry.register(f"{base}/osd", "GET", osd_list)
registry.register(f"{base}/osd", "POST", osd_create)
registry.register(f"{base}/osd/{{osdid}}", "GET", osd_get)
registry.register(f"{base}/osd/{{osdid}}", "DELETE", osd_delete)
registry.register(f"{base}/osd/{{osdid}}/in", "POST", osd_in)
registry.register(f"{base}/osd/{{osdid}}/out", "POST", osd_out)
registry.register(f"{base}/osd/{{osdid}}/scrub", "POST", osd_scrub)
registry.register(f"{base}/osd/{{osdid}}/lv-info", "GET", osd_lv_info)
registry.register(f"{base}/osd/{{osdid}}/metadata", "GET", osd_metadata)
registry.register(f"{base}/pool", "GET", pool_list)
registry.register(f"{base}/pool", "POST", pool_create)
registry.register(f"{base}/pool/{{name}}", "GET", pool_get)
registry.register(f"{base}/pool/{{name}}", "PUT", pool_update)
registry.register(f"{base}/pool/{{name}}", "DELETE", pool_delete)
registry.register(f"{base}/pool/{{name}}/status", "GET", pool_status)
registry.register(f"{base}/rules", "GET", rules)
registry.register(f"{base}/status", "GET", status)
registry.register(f"{base}/start", "POST", start)
registry.register(f"{base}/stop", "POST", stop)
registry.register(f"{base}/restart", "POST", restart)
registry.register("/cluster/ceph/status", "GET", cluster_ceph_status)
+217
View File
@@ -0,0 +1,217 @@
"""Cluster-level semantic handlers."""
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
cluster_metadata,
database,
save_cluster_metadata,
state,
subdirs,
values,
)
from app.simulation.seed import CLUSTER_ID
def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
jobs = metadata.get("replication", [])
if not isinstance(jobs, list):
return []
return [dict(item) for item in jobs if isinstance(item, dict)]
def register_cluster_handlers(registry: HandlerRegistry) -> None:
async def cluster_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs(
"acme",
"backup",
"backup-info",
"config",
"ha",
"log",
"mapping",
"nextid",
"notifications",
"options",
"replication",
"sdn",
"status",
"tasks",
)
async def cluster_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT id, name, status FROM nodes ORDER BY name"""
)
result: list[dict[str, Any]] = []
for index, row in enumerate(rows):
online = str(row["status"]) == "online"
result.append(
{
"id": str(row["id"]),
"name": str(row["name"]),
"nodeid": index,
"online": 1 if online else 0,
"local": 1 if index == 0 else 0,
"ip": f"10.32.{index // 254 + 1}.{index % 254 + 10}",
"level": "c",
"type": "node",
"quorate": 1,
}
)
return result
async def cluster_nextid(request: Request, inputs: dict[str, Any]) -> int:
requested = values(inputs).get("vmid")
if requested is not None:
candidate = int(requested)
taken = await database(request).pool.fetchval(
"""SELECT EXISTS(
SELECT 1 FROM resources WHERE kind IN ('qemu', 'lxc') AND external_id=$1
)""",
str(candidate),
)
if not taken:
return candidate
raise ApiError(400, f"VMID {candidate} already exists")
maximum = await database(request).pool.fetchval(
"""SELECT COALESCE(MAX(external_id::integer), 99)
FROM resources WHERE kind IN ('qemu', 'lxc')"""
)
return int(maximum) + 1
async def cluster_options_get(_request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
row = await database(_request).pool.fetchrow(
"SELECT metadata FROM clusters WHERE id=$1",
CLUSTER_ID,
)
metadata = state(row["metadata"]) if row is not None else {}
options = metadata.get("options", {})
if not isinstance(options, dict):
options = {}
return {
"keyboard": options.get("keyboard", "en-us"),
"email_from": options.get("email_from", "root@localhost"),
"http_proxy": options.get("http_proxy", ""),
"description": options.get("description", "Proxmox API emulator cluster"),
**options,
}
async def cluster_options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
current = await cluster_options_get(request, inputs)
provided = values(inputs)
updated = {**current, **{key: value for key, value in provided.items() if key != "node"}}
await database(request).pool.execute(
"""UPDATE clusters SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb), '{options}', $2::jsonb, true
), updated_at=now() WHERE id=$1""",
CLUSTER_ID,
json.dumps(updated, sort_keys=True),
)
return updated
async def cluster_log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
limit = int(values(inputs).get("max") or 50)
rows = await database(request).pool.fetch(
"""SELECT tl.message, tl.sequence
FROM task_logs tl
ORDER BY tl.created_at DESC, tl.sequence DESC
LIMIT $1""",
limit,
)
return [{"n": int(row["sequence"]), "t": str(row["message"])} for row in reversed(rows)]
async def cluster_tasks(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"SELECT upid FROM tasks ORDER BY created_at DESC LIMIT 1000"
)
return [{"upid": str(row["upid"])} for row in rows]
async def replication_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
return _replication_jobs(metadata)
async def replication_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
from app.handlers.common import require_value
payload = values(inputs)
guest = str(require_value(payload, "guest"))
target = str(require_value(payload, "target"))
job_id = str(payload.get("id") or f"repl-{guest.replace(':', '-')}")
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
if any(str(item.get("id")) == job_id for item in jobs):
raise ApiError(409, "replication job already exists")
job = {
"id": job_id,
"guest": guest,
"target": target,
"type": str(payload.get("type") or "local"),
"schedule": str(payload.get("schedule") or "*/15"),
"rate": int(payload.get("rate") or 1),
"comment": str(payload.get("comment") or ""),
"enabled": int(payload.get("enabled", 1)),
}
jobs.append(job)
metadata["replication"] = jobs
await save_cluster_metadata(request, metadata)
return job
async def replication_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
for job in _replication_jobs(await cluster_metadata(request)):
if str(job.get("id")) == job_id:
return job
raise ApiError(404, "replication job does not exist")
async def replication_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
for index, job in enumerate(jobs):
if str(job.get("id")) != job_id:
continue
payload = values(inputs)
updated = {
**job,
**{
key: value
for key, value in payload.items()
if key not in {"id", "delete", "digest"}
},
}
jobs[index] = updated
metadata["replication"] = jobs
await save_cluster_metadata(request, metadata)
return updated
raise ApiError(404, "replication job does not exist")
async def replication_delete(request: Request, inputs: dict[str, Any]) -> None:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
remaining = [job for job in jobs if str(job.get("id")) != job_id]
if len(remaining) == len(jobs):
raise ApiError(404, "replication job does not exist")
metadata["replication"] = remaining
await save_cluster_metadata(request, metadata)
registry.register("/cluster", "GET", cluster_index)
registry.register("/cluster/status", "GET", cluster_status)
registry.register("/cluster/nextid", "GET", cluster_nextid)
registry.register("/cluster/options", "GET", cluster_options_get)
registry.register("/cluster/options", "PUT", cluster_options_put)
registry.register("/cluster/log", "GET", cluster_log)
registry.register("/cluster/tasks", "GET", cluster_tasks)
registry.register("/cluster/replication", "GET", replication_list)
registry.register("/cluster/replication", "POST", replication_create)
registry.register("/cluster/replication/{id}", "GET", replication_get)
registry.register("/cluster/replication/{id}", "PUT", replication_update)
registry.register("/cluster/replication/{id}", "DELETE", replication_delete)
+204
View File
@@ -0,0 +1,204 @@
"""Cluster config / join / totem handlers."""
from __future__ import annotations
import secrets
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
cluster_metadata,
database,
save_cluster_metadata,
subdirs,
values,
)
def _config(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"cluster_config",
{
"clustername": "pve-simulator",
"votes": 1,
"links": {},
"join_info": {},
"totem": {"version": 2, "secauth": "on", "cluster_name": "pve-simulator"},
"qdevice": {"status": "disabled"},
"apiversion": 1,
},
)
if not isinstance(current, dict):
current = {
"clustername": "pve-simulator",
"votes": 1,
"links": {},
"join_info": {},
"totem": {"version": 2, "secauth": "on", "cluster_name": "pve-simulator"},
"qdevice": {"status": "disabled"},
"apiversion": 1,
}
metadata["cluster_config"] = current
return current
def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("apiversion", "join", "nodes", "qdevice", "totem")
async def create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata = await cluster_metadata(request)
config = _config(metadata)
if payload.get("clustername"):
config["clustername"] = str(payload["clustername"])
config.setdefault("totem", {})["cluster_name"] = str(payload["clustername"])
if "votes" in payload:
config["votes"] = payload["votes"]
if "nodeid" in payload:
config["creator_nodeid"] = payload["nodeid"]
links = {key: value for key, value in payload.items() if key.startswith("link")}
if links:
config["links"] = links
config["token"] = secrets.token_hex(16)
await save_cluster_metadata(request, metadata)
await database(request).pool.execute(
"""UPDATE clusters
SET name=$1, updated_at=now()
WHERE id=(SELECT id FROM clusters LIMIT 1)""",
str(config["clustername"]),
)
async def apiversion(_request: Request, _inputs: dict[str, Any]) -> int:
metadata = await cluster_metadata(_request)
return int(_config(metadata).get("apiversion") or 1)
async def join_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
config = _config(metadata)
node = values(inputs).get("node")
rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name")
nodelist = [
{"name": str(row["name"]), "online": 1 if row["status"] == "online" else 0}
for row in rows
]
return {
"clustername": config.get("clustername"),
"config_digest": secrets.token_hex(8),
"nodelist": nodelist,
"preferred_node": node or (nodelist[0]["name"] if nodelist else None),
"totem": config.get("totem", {}),
"links": config.get("links", {}),
}
async def join_post(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
metadata = await cluster_metadata(request)
config = _config(metadata)
hostname = str(payload.get("hostname") or payload.get("node") or "")
if not hostname:
raise ApiError(400, "parameter verification failed - 'hostname' missing")
joins = config.setdefault("join_info", {})
joins[hostname] = {
"hostname": hostname,
"fingerprint": payload.get("fingerprint"),
"nodeid": payload.get("nodeid"),
"votes": payload.get("votes", 1),
"force": payload.get("force"),
}
# password accepted but not stored in clear form
if payload.get("password"):
joins[hostname]["password_set"] = True
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
hostname,
)
if not exists:
await database(request).pool.execute(
"""INSERT INTO nodes(id, name, status, metadata)
VALUES(gen_random_uuid(), $1, 'online', '{}'::jsonb)""",
hostname,
)
await save_cluster_metadata(request, metadata)
async def nodes_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name")
metadata = await cluster_metadata(request)
config = _config(metadata)
result = []
for index, row in enumerate(rows, start=1):
result.append(
{
"node": str(row["name"]),
"nodeid": index,
"ring0_addr": f"{row['name']}.local",
"quorum_votes": config.get("votes", 1),
}
)
return result
async def nodes_add(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
metadata = await cluster_metadata(request)
config = _config(metadata)
added = config.setdefault("added_nodes", {})
added[node] = {
"node": node,
"nodeid": payload.get("nodeid"),
"new_node_ip": payload.get("new_node_ip"),
"votes": payload.get("votes", 1),
"apiversion": payload.get("apiversion"),
"force": payload.get("force"),
}
links = {key: value for key, value in payload.items() if key.startswith("link")}
if links:
added[node]["links"] = links
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
node,
)
if not exists:
await database(request).pool.execute(
"""INSERT INTO nodes(id, name, status, metadata)
VALUES(gen_random_uuid(), $1, 'online', '{}'::jsonb)""",
node,
)
await save_cluster_metadata(request, metadata)
async def nodes_delete(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
metadata = await cluster_metadata(request)
config = _config(metadata)
added = config.setdefault("added_nodes", {})
added.pop(node, None)
joins = config.setdefault("join_info", {})
joins.pop(node, None)
await save_cluster_metadata(request, metadata)
# Keep node row; mark offline to avoid cascading guest deletes.
await database(request).pool.execute(
"UPDATE nodes SET status='offline', updated_at=now() WHERE name=$1",
node,
)
async def qdevice(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
return dict(_config(metadata).get("qdevice") or {"status": "disabled"})
async def totem(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
return dict(_config(metadata).get("totem") or {})
registry.register("/cluster/config", "GET", index)
registry.register("/cluster/config", "POST", create)
registry.register("/cluster/config/apiversion", "GET", apiversion)
registry.register("/cluster/config/join", "GET", join_get)
registry.register("/cluster/config/join", "POST", join_post)
registry.register("/cluster/config/nodes", "GET", nodes_list)
registry.register("/cluster/config/nodes/{node}", "POST", nodes_add)
registry.register("/cluster/config/nodes/{node}", "DELETE", nodes_delete)
registry.register("/cluster/config/qdevice", "GET", qdevice)
registry.register("/cluster/config/totem", "GET", totem)
+688
View File
@@ -0,0 +1,688 @@
"""Additional cluster-level handlers with durable metadata persistence."""
from __future__ import annotations
import copy
import secrets
import time
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
cluster_metadata,
database,
require_node,
save_cluster_metadata,
subdirs,
values,
)
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
DEFAULT_CEPH_FLAGS: dict[str, int] = {
"nobackfill": 0,
"nodeep-scrub": 0,
"nodown": 0,
"noin": 0,
"noout": 0,
"norebalance": 0,
"norecover": 0,
"noscrub": 0,
"notieragent": 0,
"pause": 0,
}
DEFAULT_CPU_FLAGS: list[dict[str, Any]] = [
{"name": "aes", "introduces": "Westmere"},
{"name": "avx", "introduces": "SandyBridge"},
{"name": "avx2", "introduces": "Haswell"},
]
def _jobs(metadata: dict[str, Any]) -> dict[str, Any]:
jobs = metadata.setdefault("jobs", {})
if not isinstance(jobs, dict):
jobs = {}
metadata["jobs"] = jobs
sync = jobs.setdefault("realm_sync", {})
if not isinstance(sync, dict):
sync = {}
jobs["realm_sync"] = sync
return jobs
def _metrics(metadata: dict[str, Any]) -> dict[str, Any]:
metrics = metadata.setdefault("metrics", {})
if not isinstance(metrics, dict):
metrics = {}
metadata["metrics"] = metrics
servers = metrics.setdefault("servers", {})
if not isinstance(servers, dict):
servers = {}
metrics["servers"] = servers
return metrics
def _cpu_models(metadata: dict[str, Any]) -> dict[str, Any]:
models = metadata.get("qemu_cpu_models")
if not isinstance(models, dict):
models = {}
metadata["qemu_cpu_models"] = models
return models
def _ha_rules_store(metadata: dict[str, Any]) -> list[dict[str, Any]]:
rules = metadata.get("ha_rules")
if isinstance(rules, dict):
return [
{"rule": str(name), **dict(value)}
for name, value in rules.items()
if isinstance(value, dict)
]
if isinstance(rules, list):
return [dict(item) for item in rules if isinstance(item, dict)]
defaults = [
{"rule": "node-fencing", "type": "node", "action": "restart"},
{"rule": "service-ha", "type": "resource", "action": "failover"},
]
metadata["ha_rules"] = defaults
return list(defaults)
def _save_ha_rules(metadata: dict[str, Any], rules: list[dict[str, Any]]) -> None:
metadata["ha_rules"] = rules
def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
jobs = metadata.get("replication", [])
if not isinstance(jobs, list):
return []
return [dict(item) for item in jobs if isinstance(item, dict)]
def _ceph(metadata: dict[str, Any]) -> dict[str, Any]:
ceph = metadata.get("ceph")
if not isinstance(ceph, dict):
ceph = {}
flags = ceph.get("flags")
if not isinstance(flags, dict):
flags = copy.deepcopy(DEFAULT_CEPH_FLAGS)
else:
merged = copy.deepcopy(DEFAULT_CEPH_FLAGS)
merged.update({str(key): int(value) for key, value in flags.items()})
flags = merged
ceph["flags"] = flags
metadata["ceph"] = ceph
return ceph
async def _cluster_task(request: Request, *, task_type: str, worker: str) -> str:
from app.db.primitives import ConflictError
pool = database(request).pool
node = await pool.fetchval("SELECT name FROM nodes ORDER BY name LIMIT 1") or "localhost"
upid = str(Upid.allocate(str(node), worker, "0", str(request.state.principal)))
try:
task = await TaskRepository(pool).create(
upid=upid,
task_type=task_type,
payload={"cluster": True},
resource_key=f"cluster:{task_type}:{secrets.token_hex(4)}",
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def _bulk_guest_status(request: Request, status: str) -> None:
await database(request).pool.execute(
"""UPDATE resources
SET state = jsonb_set(COALESCE(state, '{}'::jsonb), '{status}', to_jsonb($1::text), true),
updated_at=now()
WHERE kind IN ('qemu', 'lxc')""",
status,
)
def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
async def jobs_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("realm-sync", "schedule-analyze")
async def realm_sync_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
items = [
{"id": job_id, **dict(payload)}
for job_id, payload in sorted(jobs.get("realm_sync", {}).items())
if isinstance(payload, dict)
]
return items
async def realm_sync_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
payload = jobs.get("realm_sync", {}).get(job_id)
if not isinstance(payload, dict):
raise ApiError(404, "realm-sync job does not exist")
return {"id": job_id, **payload}
async def realm_sync_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
job_id = str(payload["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
sync = jobs.setdefault("realm_sync", {})
if job_id in sync:
raise ApiError(409, "realm-sync job already exists")
entry = {
key: value for key, value in payload.items() if key not in {"id", "delete", "digest"}
}
entry.setdefault("schedule", "0 0 * * *")
entry.setdefault("enabled", 1)
entry.setdefault("realm", str(payload.get("realm") or "pam"))
sync[job_id] = entry
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
return {"id": job_id, **entry}
async def realm_sync_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
job_id = str(payload["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
sync = jobs.setdefault("realm_sync", {})
if job_id not in sync:
raise ApiError(404, "realm-sync job does not exist")
updated = {
**sync[job_id],
**{
key: value
for key, value in payload.items()
if key not in {"id", "delete", "digest"}
},
}
sync[job_id] = updated
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
return {"id": job_id, **updated}
async def realm_sync_delete(request: Request, inputs: dict[str, Any]) -> None:
job_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
sync = jobs.setdefault("realm_sync", {})
if job_id not in sync:
raise ApiError(404, "realm-sync job does not exist")
del sync[job_id]
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
async def schedule_analyze(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
schedule = str(values(inputs).get("schedule") or "*/15")
metadata = await cluster_metadata(request)
jobs = _jobs(metadata)
jobs["last_schedule_analyze"] = {"schedule": schedule, "at": int(time.time())}
metadata["jobs"] = jobs
await save_cluster_metadata(request, metadata)
now = int(time.time())
return [{"timestamp": now + offset * 900, "utc": True} for offset in range(4)]
async def metrics_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("export", "server")
async def metrics_export(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
return {
"data": metrics.get("export_data")
or '# HELP pve_up Node is up\npve_up{node="pve01"} 1\n',
"timestamp": int(time.time()),
}
async def metrics_server_list(
request: Request, _inputs: dict[str, Any]
) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
return [
{"id": server_id, **dict(payload)}
for server_id, payload in sorted(metrics.get("servers", {}).items())
if isinstance(payload, dict)
]
async def metrics_server_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
server_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
payload = metrics.get("servers", {}).get(server_id)
if not isinstance(payload, dict):
raise ApiError(404, "metrics server does not exist")
return {"id": server_id, **payload}
async def metrics_server_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
server_id = str(payload["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.setdefault("servers", {})
if server_id in servers:
raise ApiError(409, "metrics server already exists")
entry = {
key: value for key, value in payload.items() if key not in {"id", "delete", "digest"}
}
entry.setdefault("type", "influxdb")
entry.setdefault("server", "127.0.0.1")
entry.setdefault("port", 8086)
entry.setdefault("enable", 1)
servers[server_id] = entry
metadata["metrics"] = metrics
await save_cluster_metadata(request, metadata)
return {"id": server_id, **entry}
async def metrics_server_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
server_id = str(payload["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.setdefault("servers", {})
if server_id not in servers:
raise ApiError(404, "metrics server does not exist")
updated = {
**servers[server_id],
**{
key: value
for key, value in payload.items()
if key not in {"id", "delete", "digest"}
},
}
servers[server_id] = updated
metadata["metrics"] = metrics
await save_cluster_metadata(request, metadata)
return {"id": server_id, **updated}
async def metrics_server_delete(request: Request, inputs: dict[str, Any]) -> None:
server_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
metrics = _metrics(metadata)
servers = metrics.setdefault("servers", {})
if server_id not in servers:
raise ApiError(404, "metrics server does not exist")
del servers[server_id]
metadata["metrics"] = metrics
await save_cluster_metadata(request, metadata)
async def qemu_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("cpu-flags", "custom-cpu-models")
async def qemu_cpu_flags(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(DEFAULT_CPU_FLAGS)
async def cpu_models_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
return [
{"name": name, **dict(payload)}
for name, payload in sorted(models.items())
if isinstance(payload, dict)
]
async def cpu_models_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
name = str(payload.get("name") or payload.get("cputype") or "")
if not name:
raise ApiError(400, "parameter verification failed - 'name' missing")
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
if name in models:
raise ApiError(409, "custom cpu model already exists")
entry = {
key: value
for key, value in payload.items()
if key not in {"name", "cputype", "delete", "digest"}
}
entry.setdefault("vendor", "Custom")
models[name] = entry
metadata["qemu_cpu_models"] = models
await save_cluster_metadata(request, metadata)
return {"name": name, **entry}
async def cpu_models_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["cputype"])
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
payload = models.get(name)
if not isinstance(payload, dict):
raise ApiError(404, "custom cpu model does not exist")
return {"name": name, **payload}
async def cpu_models_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
name = str(payload["cputype"])
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
if name not in models:
raise ApiError(404, "custom cpu model does not exist")
updated = {
**models[name],
**{
key: value
for key, value in payload.items()
if key not in {"cputype", "delete", "digest"}
},
}
models[name] = updated
metadata["qemu_cpu_models"] = models
await save_cluster_metadata(request, metadata)
return {"name": name, **updated}
async def cpu_models_delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["cputype"])
metadata = await cluster_metadata(request)
models = _cpu_models(metadata)
if name not in models:
raise ApiError(404, "custom cpu model does not exist")
del models[name]
metadata["qemu_cpu_models"] = models
await save_cluster_metadata(request, metadata)
async def bulk_action_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("guest")
async def bulk_guest_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("migrate", "shutdown", "start", "suspend")
async def bulk_guest_action(request: Request, inputs: dict[str, Any], action: str) -> str:
payload = values(inputs)
metadata = await cluster_metadata(request)
metadata["last_bulk_action"] = {
"action": action,
"payload": {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
},
"at": int(time.time()),
}
await save_cluster_metadata(request, metadata)
if action == "start":
await _bulk_guest_status(request, "running")
elif action == "shutdown":
await _bulk_guest_status(request, "stopped")
elif action == "suspend":
await _bulk_guest_status(request, "paused")
elif action == "migrate":
target = str(payload.get("target") or "")
if target:
target_row = await database(request).pool.fetchrow(
"SELECT id FROM nodes WHERE name=$1", target
)
if target_row is None:
raise ApiError(404, "target node does not exist")
vms = payload.get("vms") or payload.get("guests") or ""
if isinstance(vms, str) and vms:
ids = [part.strip() for part in vms.split(",") if part.strip()]
for vmid in ids:
await database(request).pool.execute(
"""UPDATE resources SET node_id=$2, updated_at=now()
WHERE kind IN ('qemu', 'lxc') AND external_id=$1""",
vmid,
target_row["id"],
)
return await _cluster_task(request, task_type=f"bulk-{action}", worker=f"bulk{action}")
async def cluster_ceph_index(
_request: Request, _inputs: dict[str, Any]
) -> list[dict[str, str]]:
return subdirs("flags", "metadata", "status")
async def ceph_flags_get(request: Request, _inputs: dict[str, Any]) -> dict[str, int]:
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
await save_cluster_metadata(request, metadata)
return {str(key): int(value) for key, value in ceph["flags"].items()}
async def ceph_flags_put(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
payload = values(inputs)
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = dict(ceph["flags"])
for key, value in payload.items():
if key in {"delete", "digest"}:
continue
flags[str(key)] = int(value)
ceph["flags"] = flags
metadata["ceph"] = ceph
await save_cluster_metadata(request, metadata)
return {str(key): int(value) for key, value in flags.items()}
async def ceph_flag_get(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
flag = str(values(inputs)["flag"])
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = ceph["flags"]
if flag not in flags:
raise ApiError(404, "ceph flag does not exist")
return {flag: int(flags[flag])}
async def ceph_flag_put(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
payload = values(inputs)
flag = str(payload["flag"])
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
flags = dict(ceph["flags"])
if "value" in payload:
flags[flag] = int(payload["value"])
elif flag in payload:
flags[flag] = int(payload[flag])
else:
flags[flag] = 1
ceph["flags"] = flags
metadata["ceph"] = ceph
await save_cluster_metadata(request, metadata)
return {flag: int(flags[flag])}
async def ceph_metadata(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
ceph = _ceph(metadata)
await save_cluster_metadata(request, metadata)
return {
"version": ceph.get("version") or {"str": "18.2.2", "parts": [18, 2, 2]},
"fsid": ceph.get("config", {}).get("fsid")
if isinstance(ceph.get("config"), dict)
else "pve-simulator-fsid",
"initialized": int(bool(ceph.get("initialized", True))),
"flags": ceph.get("flags", {}),
}
async def ha_rule_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
rule = str(payload.get("rule") or payload.get("name") or "")
if not rule:
raise ApiError(400, "parameter verification failed - 'rule' missing")
metadata = await cluster_metadata(request)
rules = _ha_rules_store(metadata)
if any(str(item.get("rule")) == rule for item in rules):
raise ApiError(409, "HA rule already exists")
entry = {key: value for key, value in payload.items() if key not in {"delete", "digest"}}
entry["rule"] = rule
entry.setdefault("type", "resource")
entry.setdefault("action", "migrate")
rules.append(entry)
_save_ha_rules(metadata, rules)
await save_cluster_metadata(request, metadata)
async def ha_rule_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
rule = str(values(inputs)["rule"])
metadata = await cluster_metadata(request)
for item in _ha_rules_store(metadata):
if str(item.get("rule")) == rule:
return dict(item)
raise ApiError(404, "HA rule does not exist")
async def ha_rule_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
rule = str(payload["rule"])
metadata = await cluster_metadata(request)
rules = _ha_rules_store(metadata)
updated: list[dict[str, Any]] = []
found = False
for item in rules:
if str(item.get("rule")) != rule:
updated.append(item)
continue
found = True
merged = {
**item,
**{
key: value
for key, value in payload.items()
if key not in {"rule", "delete", "digest"}
},
}
merged["rule"] = rule
updated.append(merged)
if not found:
raise ApiError(404, "HA rule does not exist")
_save_ha_rules(metadata, updated)
await save_cluster_metadata(request, metadata)
async def ha_rule_delete(request: Request, inputs: dict[str, Any]) -> None:
rule = str(values(inputs)["rule"])
metadata = await cluster_metadata(request)
rules = _ha_rules_store(metadata)
remaining = [item for item in rules if str(item.get("rule")) != rule]
if len(remaining) == len(rules):
raise ApiError(404, "HA rule does not exist")
_save_ha_rules(metadata, remaining)
await save_cluster_metadata(request, metadata)
async def node_replication_list(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
return [
job
for job in jobs
if str(job.get("source") or job.get("node") or node) == node
or job.get("source") is None
]
async def node_replication_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
job_id = str(values(inputs)["id"])
await require_node(request, node)
for job in _replication_jobs(await cluster_metadata(request)):
if str(job.get("id")) == job_id:
return dict(job)
raise ApiError(404, "replication job does not exist")
async def node_replication_log(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
job = await node_replication_get(request, inputs)
log = job.get("log")
if isinstance(log, list):
return [dict(item) for item in log if isinstance(item, dict)]
return [{"t": int(time.time()), "n": 0, "msg": f"replication idle for {job.get('id')}"}]
async def node_replication_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
job = await node_replication_get(request, inputs)
return {
"id": job.get("id"),
"last_sync": job.get("last_sync", 0),
"duration": job.get("duration", 0),
"fail_count": job.get("fail_count", 0),
"error": job.get("error", ""),
"state": job.get("state", "OK"),
}
async def node_replication_schedule_now(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
job_id = str(values(inputs)["id"])
await require_node(request, node)
metadata = await cluster_metadata(request)
jobs = _replication_jobs(metadata)
found = False
for job in jobs:
if str(job.get("id")) != job_id:
continue
found = True
job["last_sync"] = int(time.time())
job["state"] = "OK"
job["schedule_now"] = 1
if not found:
raise ApiError(404, "replication job does not exist")
metadata["replication"] = jobs
await save_cluster_metadata(request, metadata)
registry.register("/cluster/jobs", "GET", jobs_index)
registry.register("/cluster/jobs/realm-sync", "GET", realm_sync_list)
registry.register("/cluster/jobs/realm-sync/{id}", "GET", realm_sync_get)
registry.register("/cluster/jobs/realm-sync/{id}", "POST", realm_sync_create)
registry.register("/cluster/jobs/realm-sync/{id}", "PUT", realm_sync_update)
registry.register("/cluster/jobs/realm-sync/{id}", "DELETE", realm_sync_delete)
registry.register("/cluster/jobs/schedule-analyze", "GET", schedule_analyze)
registry.register("/cluster/metrics", "GET", metrics_index)
registry.register("/cluster/metrics/export", "GET", metrics_export)
registry.register("/cluster/metrics/server", "GET", metrics_server_list)
registry.register("/cluster/metrics/server/{id}", "GET", metrics_server_get)
registry.register("/cluster/metrics/server/{id}", "POST", metrics_server_create)
registry.register("/cluster/metrics/server/{id}", "PUT", metrics_server_update)
registry.register("/cluster/metrics/server/{id}", "DELETE", metrics_server_delete)
registry.register("/cluster/qemu", "GET", qemu_index)
registry.register("/cluster/qemu/cpu-flags", "GET", qemu_cpu_flags)
registry.register("/cluster/qemu/custom-cpu-models", "GET", cpu_models_list)
registry.register("/cluster/qemu/custom-cpu-models", "POST", cpu_models_create)
registry.register("/cluster/qemu/custom-cpu-models/{cputype}", "GET", cpu_models_get)
registry.register("/cluster/qemu/custom-cpu-models/{cputype}", "PUT", cpu_models_update)
registry.register("/cluster/qemu/custom-cpu-models/{cputype}", "DELETE", cpu_models_delete)
registry.register("/cluster/bulk-action", "GET", bulk_action_index)
registry.register("/cluster/bulk-action/guest", "GET", bulk_guest_index)
registry.register(
"/cluster/bulk-action/guest/migrate",
"POST",
lambda request, inputs: bulk_guest_action(request, inputs, "migrate"),
)
registry.register(
"/cluster/bulk-action/guest/shutdown",
"POST",
lambda request, inputs: bulk_guest_action(request, inputs, "shutdown"),
)
registry.register(
"/cluster/bulk-action/guest/start",
"POST",
lambda request, inputs: bulk_guest_action(request, inputs, "start"),
)
registry.register(
"/cluster/bulk-action/guest/suspend",
"POST",
lambda request, inputs: bulk_guest_action(request, inputs, "suspend"),
)
registry.register("/cluster/ceph", "GET", cluster_ceph_index)
registry.register("/cluster/ceph/flags", "GET", ceph_flags_get)
registry.register("/cluster/ceph/flags", "PUT", ceph_flags_put)
registry.register("/cluster/ceph/flags/{flag}", "GET", ceph_flag_get)
registry.register("/cluster/ceph/flags/{flag}", "PUT", ceph_flag_put)
registry.register("/cluster/ceph/metadata", "GET", ceph_metadata)
registry.register("/cluster/ha/rules", "POST", ha_rule_create)
registry.register("/cluster/ha/rules/{rule}", "GET", ha_rule_get)
registry.register("/cluster/ha/rules/{rule}", "PUT", ha_rule_update)
registry.register("/cluster/ha/rules/{rule}", "DELETE", ha_rule_delete)
registry.register("/nodes/{node}/replication", "GET", node_replication_list)
registry.register("/nodes/{node}/replication/{id}", "GET", node_replication_get)
registry.register("/nodes/{node}/replication/{id}/log", "GET", node_replication_log)
registry.register("/nodes/{node}/replication/{id}/status", "GET", node_replication_status)
registry.register(
"/nodes/{node}/replication/{id}/schedule_now", "POST", node_replication_schedule_now
)
+145
View File
@@ -0,0 +1,145 @@
"""Shared handler helpers."""
from __future__ import annotations
import json
import re
from collections.abc import Mapping
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.db.pool import AsyncpgDatabase
def database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def values(inputs: dict[str, Any]) -> dict[str, Any]:
return cast(dict[str, Any], inputs["values"])
def require_value(payload: Mapping[str, Any], key: str) -> Any:
if key not in payload or payload[key] in {None, ""}:
raise ApiError(400, f"parameter '{key}' is required")
return payload[key]
def state(value: object) -> dict[str, Any]:
if isinstance(value, str):
return cast(dict[str, Any], json.loads(value))
return dict(cast(Mapping[str, Any], value))
def subdirs(*names: str) -> list[dict[str, str]]:
return [{"subdir": name} for name in names]
_SIZE_RE = re.compile(r"^(?P<value>\d+)(?P<unit>[KMGT]?)$", re.IGNORECASE)
_UNITS = {"": 1, "K": 2**10, "M": 2**20, "G": 2**30, "T": 2**40}
def parse_size_bytes(value: str) -> int:
match = _SIZE_RE.fullmatch(value.strip())
if match is None:
raise ValueError(f"invalid disk size: {value}")
return int(match.group("value")) * _UNITS[match.group("unit").upper()]
def resize_size_bytes(value: str, current: int) -> int:
if value.startswith("+"):
return current + parse_size_bytes(value[1:])
result = parse_size_bytes(value)
if result < current:
raise ValueError("shrinking disks is not supported")
return result
def replace_disk_size(value: str, size: int) -> str:
parts = [part for part in value.split(",") if not part.startswith("size=")]
parts.append(f"size={size // 2**30}G" if size % 2**30 == 0 else f"size={size}")
return ",".join(parts)
def disk_size_bytes(value: str) -> int:
for part in value.split(","):
if part.startswith("size="):
return parse_size_bytes(part.removeprefix("size="))
return 0
async def require_node(request: Request, node: str) -> None:
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
node,
)
if not exists:
raise ApiError(404, "node does not exist")
async def cluster_metadata(request: Request) -> dict[str, Any]:
from app.simulation.seed import CLUSTER_ID
row = await database(request).pool.fetchrow(
"SELECT metadata FROM clusters WHERE id=$1",
CLUSTER_ID,
)
return state(row["metadata"]) if row is not None else {}
async def save_cluster_metadata(request: Request, metadata: dict[str, Any]) -> None:
from app.simulation.seed import CLUSTER_ID
await database(request).pool.execute(
"UPDATE clusters SET metadata=$2::jsonb, updated_at=now() WHERE id=$1",
CLUSTER_ID,
json.dumps(metadata, sort_keys=True),
)
async def node_metadata(request: Request, node: str) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"SELECT metadata FROM nodes WHERE name=$1",
node,
)
if row is None:
raise ApiError(404, "node does not exist")
return state(row["metadata"])
async def save_node_metadata(request: Request, node: str, metadata: dict[str, Any]) -> None:
status = await database(request).pool.execute(
"UPDATE nodes SET metadata=$2::jsonb, updated_at=now() WHERE name=$1",
node,
json.dumps(metadata, sort_keys=True),
)
if status != "UPDATE 1":
raise ApiError(404, "node does not exist")
def storage_payload(row: Any) -> dict[str, Any]:
config = state(row["config"])
content = config.get("content", [])
if isinstance(content, list):
content_str = ",".join(str(item) for item in content)
else:
content_str = str(content)
total = int(row["capacity_bytes"] or 0)
used = int(row["used_bytes"] or 0)
avail = max(total - used, 0)
payload: dict[str, Any] = {
"storage": str(row["storage_id"]),
"type": str(row["storage_type"]),
"shared": int(bool(row["shared"])),
"content": content_str,
"active": 1,
"enabled": 1,
"total": total,
"used": used,
"avail": avail,
}
if total:
payload["used_fraction"] = used / total
return payload
+162
View File
@@ -0,0 +1,162 @@
"""First read/login semantic service handlers."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.contracts.runtime import runtime_version_payload
from app.db.pool import AsyncpgDatabase
from app.handlers.access import register_access_handlers
from app.handlers.acme import register_acme_handlers
from app.handlers.backup import register_backup_handlers
from app.handlers.ceph import register_ceph_handlers
from app.handlers.cluster import register_cluster_handlers
from app.handlers.cluster_config import register_cluster_config_handlers
from app.handlers.cluster_extra import register_cluster_extra_handlers
from app.handlers.common import require_node, subdirs
from app.handlers.firewall import register_firewall_handlers
from app.handlers.ha import register_ha_handlers
from app.handlers.legacy_aliases import register_legacy_aliases
from app.handlers.lxc import register_lxc_handlers
from app.handlers.mapping import register_mapping_handlers
from app.handlers.nodes import register_node_ops_handlers
from app.handlers.nodes_extra import register_nodes_extra_handlers
from app.handlers.notifications import register_notifications_handlers
from app.handlers.pools import register_pool_handlers
from app.handlers.qemu import register_qemu_handlers
from app.handlers.sdn import register_sdn_handlers
from app.handlers.storage import register_storage_handlers
from app.security.auth import csrf_token, issue_ticket, verify_secret
def _database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def build_core_handlers(settings: Settings) -> HandlerRegistry:
registry = HandlerRegistry()
async def version(request: Request, _inputs: dict[str, Any]) -> dict[str, str]:
return runtime_version_payload(request)
async def login(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = cast(dict[str, Any], inputs["values"])
username = str(values["username"])
password = str(values["password"])
row = await _database(request).pool.fetchrow(
"SELECT name, password_hash FROM principals WHERE name=$1", username
)
if (
row is None
or row["password_hash"] is None
or not verify_secret(password, str(row["password_hash"]))
):
raise ApiError(401, "authentication failure")
key = settings.ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(username, key)
return {
"username": username,
"ticket": ticket,
"CSRFPreventionToken": csrf_token(ticket, key),
"cap": {"vms": {"VM.Audit": 1, "VM.PowerMgmt": 1}},
}
async def nodes(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await _database(request).pool.fetch(
"SELECT name AS node, status FROM nodes ORDER BY name"
)
return [{"node": str(row["node"]), "status": str(row["status"])} for row in rows]
async def node_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(cast(dict[str, Any], inputs["values"])["node"])
row = await _database(request).pool.fetchrow(
"SELECT name, status FROM nodes WHERE name=$1", node
)
if row is None:
raise ApiError(404, "node does not exist")
return {
"status": str(row["status"]),
"node": str(row["name"]),
"uptime": 0,
"cpu": 0.0,
"memory": {"used": 0, "total": 0},
}
async def resources(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await _database(request).pool.fetch(
"""SELECT r.kind AS type, r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
ORDER BY r.kind, r.external_id"""
)
result: list[dict[str, Any]] = []
for row in rows:
raw_state = row["state"]
state = json.loads(raw_state) if isinstance(raw_state, str) else dict(raw_state)
result.append(
{
"type": str(row["type"]),
"id": f"{row['type']}/{row['external_id']}",
"node": str(row["node"]),
**state,
}
)
return result
async def node_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(cast(dict[str, Any], inputs["values"])["node"])
await require_node(request, node)
return subdirs(
"apt",
"ceph",
"disks",
"firewall",
"lxc",
"network",
"qemu",
"services",
"status",
"storage",
"tasks",
"version",
"vzdump",
)
async def node_version(request: Request, inputs: dict[str, Any]) -> dict[str, str]:
node = str(cast(dict[str, Any], inputs["values"])["node"])
await require_node(request, node)
return runtime_version_payload(request)
registry.register("/version", "GET", version)
registry.register("/access/ticket", "POST", login)
registry.register("/nodes", "GET", nodes)
registry.register("/nodes/{node}", "GET", node_index)
registry.register("/nodes/{node}/status", "GET", node_status)
registry.register("/nodes/{node}/version", "GET", node_version)
registry.register("/cluster/resources", "GET", resources)
register_access_handlers(registry)
register_cluster_handlers(registry)
register_notifications_handlers(registry)
register_mapping_handlers(registry)
register_acme_handlers(registry)
register_cluster_config_handlers(registry)
register_sdn_handlers(registry)
register_storage_handlers(registry)
register_pool_handlers(registry)
register_ceph_handlers(registry)
register_backup_handlers(registry)
register_ha_handlers(registry)
register_node_ops_handlers(registry)
register_firewall_handlers(registry)
register_qemu_handlers(registry)
register_lxc_handlers(registry)
register_nodes_extra_handlers(registry)
register_cluster_extra_handlers(registry)
register_legacy_aliases(registry)
return registry
+526
View File
@@ -0,0 +1,526 @@
"""Firewall handlers backed by cluster metadata."""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, require_node, state, subdirs, values
from app.simulation.seed import CLUSTER_ID
DEFAULT_OPTIONS = {
"enable": 1,
"policy_in": "DROP",
"policy_out": "ACCEPT",
"log_level_in": "nolog",
"log_level_out": "nolog",
}
DEFAULT_MACROS = [
{"macro": "SSH", "descr": "Secure Shell"},
{"macro": "HTTPS", "descr": "Secure web server"},
{"macro": "HTTP", "descr": "Web server"},
]
ScopeFn = Callable[[dict[str, Any]], str]
async def _load_firewall(request: Request) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"SELECT metadata FROM clusters WHERE id=$1",
CLUSTER_ID,
)
metadata = state(row["metadata"]) if row is not None else {}
firewall = metadata.get("firewall")
return dict(firewall) if isinstance(firewall, dict) else {}
async def _save_firewall(request: Request, firewall: dict[str, Any]) -> None:
await database(request).pool.execute(
"""UPDATE clusters SET metadata = jsonb_set(
COALESCE(metadata, '{}'::jsonb), '{firewall}', $2::jsonb, true
), updated_at=now() WHERE id=$1""",
CLUSTER_ID,
json.dumps(firewall, sort_keys=True),
)
def _scope_data(firewall: dict[str, Any], scope: str) -> dict[str, Any]:
scopes = firewall.setdefault("scopes", {})
if scope not in scopes or not isinstance(scopes[scope], dict):
scopes[scope] = {
"options": dict(DEFAULT_OPTIONS),
"rules": [],
"aliases": {},
"ipset": {},
"groups": {},
"log": [],
}
section = scopes[scope]
section.setdefault("options", dict(DEFAULT_OPTIONS))
section.setdefault("rules", [])
section.setdefault("aliases", {})
section.setdefault("ipset", {})
section.setdefault("groups", {})
section.setdefault("log", [])
return cast(dict[str, Any], section)
def register_firewall_handlers(registry: HandlerRegistry) -> None:
def register_scope(
base: str,
scope_fn: ScopeFn,
*,
require_node_name: bool = False,
include_macros: bool = False,
include_groups: bool = False,
) -> None:
async def _ready(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
if require_node_name:
await require_node(request, str(payload["node"]))
return payload
async def index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await _ready(request, inputs)
names = ["aliases", "ipset", "log", "options", "refs", "rules"]
if include_groups:
names.insert(2, "groups")
if include_macros:
names.append("macros")
return subdirs(*names)
async def options_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
return dict(_scope_data(firewall, scope_fn(payload)).get("options", DEFAULT_OPTIONS))
async def options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
current = dict(section.get("options", DEFAULT_OPTIONS))
for key, value in payload.items():
if key in {"node", "vmid", "delete", "digest"}:
continue
current[key] = value
section["options"] = current
await _save_firewall(request, firewall)
return current
async def rules_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
rules = _scope_data(firewall, scope_fn(payload)).get("rules", [])
return list(rules) if isinstance(rules, list) else []
async def rules_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
rules = section.setdefault("rules", [])
if not isinstance(rules, list):
rules = section["rules"] = []
rule = {
key: value for key, value in payload.items() if key not in {"node", "vmid", "pos"}
}
rule["pos"] = len(rules)
rules.append(rule)
await _save_firewall(request, firewall)
async def rule_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
pos = int(values(inputs)["pos"])
rules = await rules_list(request, inputs)
if pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
return dict(rules[pos])
async def rule_update(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
pos = int(payload["pos"])
firewall = await _load_firewall(request)
rules = _scope_data(firewall, scope_fn(payload)).setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
rules[pos] = {
**rules[pos],
**{k: v for k, v in payload.items() if k not in {"node", "vmid"}},
}
await _save_firewall(request, firewall)
async def rule_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
pos = int(payload["pos"])
firewall = await _load_firewall(request)
rules = _scope_data(firewall, scope_fn(payload)).setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
del rules[pos]
for index, rule in enumerate(rules):
if isinstance(rule, dict):
rule["pos"] = index
await _save_firewall(request, firewall)
async def aliases_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
aliases = _scope_data(firewall, scope_fn(payload)).get("aliases", {})
if not isinstance(aliases, dict):
return []
return [
{"name": name, **{k: v for k, v in data.items() if k != "name"}}
for name, data in sorted(aliases.items())
if isinstance(data, dict)
]
async def aliases_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
aliases = section.setdefault("aliases", {})
if name in aliases:
raise ApiError(400, f"alias '{name}' already exists")
aliases[name] = {
"name": name,
"cidr": str(payload.get("cidr") or ""),
"comment": str(payload.get("comment") or ""),
}
await _save_firewall(request, firewall)
async def aliases_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
alias = _scope_data(firewall, scope_fn(payload)).get("aliases", {}).get(name)
if not isinstance(alias, dict):
raise ApiError(404, "alias does not exist")
return dict(alias)
async def aliases_update(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
aliases = _scope_data(firewall, scope_fn(payload)).setdefault("aliases", {})
if name not in aliases or not isinstance(aliases[name], dict):
raise ApiError(404, "alias does not exist")
current = dict(aliases[name])
if payload.get("rename"):
new_name = str(payload["rename"])
if new_name in aliases and new_name != name:
raise ApiError(400, f"alias '{new_name}' already exists")
del aliases[name]
name = new_name
current["name"] = new_name
for key in ("cidr", "comment"):
if key in payload:
current[key] = payload[key]
aliases[name] = current
await _save_firewall(request, firewall)
async def aliases_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
aliases = _scope_data(firewall, scope_fn(payload)).setdefault("aliases", {})
if name not in aliases:
raise ApiError(404, "alias does not exist")
del aliases[name]
await _save_firewall(request, firewall)
async def ipset_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).get("ipset", {})
if not isinstance(ipsets, dict):
return []
return [
{"name": name, "comment": data.get("comment", "")}
for name, data in sorted(ipsets.items())
if isinstance(data, dict)
]
async def ipset_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name in ipsets:
raise ApiError(400, f"ipset '{name}' already exists")
ipsets[name] = {
"name": name,
"comment": str(payload.get("comment") or ""),
"entries": {},
}
await _save_firewall(request, firewall)
async def ipset_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
ipset = _scope_data(firewall, scope_fn(payload)).get("ipset", {}).get(name)
if not isinstance(ipset, dict):
raise ApiError(404, "ipset does not exist")
entries = ipset.get("entries", {})
if not isinstance(entries, dict):
return []
return [
{"cidr": cidr, **{k: v for k, v in data.items() if k != "cidr"}}
for cidr, data in sorted(entries.items())
if isinstance(data, dict)
]
async def ipset_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name not in ipsets:
raise ApiError(404, "ipset does not exist")
del ipsets[name]
await _save_firewall(request, firewall)
async def ipset_entry_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
cidr = str(payload["cidr"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name not in ipsets or not isinstance(ipsets[name], dict):
raise ApiError(404, "ipset does not exist")
entries = ipsets[name].setdefault("entries", {})
if cidr in entries:
raise ApiError(400, f"ip '{cidr}' already exists in ipset")
entries[cidr] = {
"cidr": cidr,
"comment": str(payload.get("comment") or ""),
"nomatch": int(bool(payload.get("nomatch"))),
}
await _save_firewall(request, firewall)
async def ipset_entry_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = await _ready(request, inputs)
name = str(payload["name"])
cidr = str(payload["cidr"])
firewall = await _load_firewall(request)
ipset = _scope_data(firewall, scope_fn(payload)).get("ipset", {}).get(name)
if not isinstance(ipset, dict):
raise ApiError(404, "ipset does not exist")
entry = ipset.get("entries", {}).get(cidr)
if not isinstance(entry, dict):
raise ApiError(404, "ipset entry does not exist")
return dict(entry)
async def ipset_entry_update(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
cidr = str(payload["cidr"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name not in ipsets or not isinstance(ipsets[name], dict):
raise ApiError(404, "ipset does not exist")
entries = ipsets[name].setdefault("entries", {})
if cidr not in entries:
raise ApiError(404, "ipset entry does not exist")
current = dict(entries[cidr])
if "comment" in payload:
current["comment"] = payload["comment"]
if "nomatch" in payload:
current["nomatch"] = int(bool(payload.get("nomatch")))
entries[cidr] = current
await _save_firewall(request, firewall)
async def ipset_entry_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
name = str(payload["name"])
cidr = str(payload["cidr"])
firewall = await _load_firewall(request)
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
if name not in ipsets or not isinstance(ipsets[name], dict):
raise ApiError(404, "ipset does not exist")
entries = ipsets[name].setdefault("entries", {})
if cidr not in entries:
raise ApiError(404, "ipset entry does not exist")
del entries[cidr]
await _save_firewall(request, firewall)
async def refs_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
section = _scope_data(firewall, scope_fn(payload))
refs: list[dict[str, Any]] = []
for name in section.get("aliases", {}):
refs.append({"type": "alias", "name": name})
for name in section.get("ipset", {}):
refs.append({"type": "ipset", "name": name})
return refs
async def log_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
log = _scope_data(firewall, scope_fn(payload)).get("log", [])
return list(log) if isinstance(log, list) else []
async def macros_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(DEFAULT_MACROS)
async def groups_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).get("groups", {})
if not isinstance(groups, dict):
return []
return [
{"group": name, "comment": data.get("comment", "")}
for name, data in sorted(groups.items())
if isinstance(data, dict)
]
async def groups_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group in groups:
raise ApiError(400, f"security group '{group}' already exists")
groups[group] = {"comment": str(payload.get("comment") or ""), "rules": []}
await _save_firewall(request, firewall)
async def group_rules(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = await _ready(request, inputs)
group = str(payload["group"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).get("groups", {})
if group not in groups or not isinstance(groups[group], dict):
raise ApiError(404, "security group does not exist")
rules = groups[group].get("rules", [])
return list(rules) if isinstance(rules, list) else []
async def group_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group not in groups:
raise ApiError(404, "security group does not exist")
del groups[group]
await _save_firewall(request, firewall)
async def group_rule_create(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group not in groups or not isinstance(groups[group], dict):
raise ApiError(404, "security group does not exist")
rules = groups[group].setdefault("rules", [])
if not isinstance(rules, list):
rules = groups[group]["rules"] = []
rule = {
key: value
for key, value in payload.items()
if key not in {"node", "vmid", "group", "pos"}
}
rule["pos"] = len(rules)
rules.append(rule)
await _save_firewall(request, firewall)
async def group_rule_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
rules = await group_rules(request, inputs)
pos = int(values(inputs)["pos"])
if pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
return dict(rules[pos])
async def group_rule_update(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
pos = int(payload["pos"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group not in groups or not isinstance(groups[group], dict):
raise ApiError(404, "security group does not exist")
rules = groups[group].setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
rules[pos] = {
**rules[pos],
**{k: v for k, v in payload.items() if k not in {"node", "vmid", "group"}},
}
await _save_firewall(request, firewall)
async def group_rule_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = await _ready(request, inputs)
group = str(payload["group"])
pos = int(payload["pos"])
firewall = await _load_firewall(request)
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
if group not in groups or not isinstance(groups[group], dict):
raise ApiError(404, "security group does not exist")
rules = groups[group].setdefault("rules", [])
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
raise ApiError(404, "firewall rule does not exist")
del rules[pos]
await _save_firewall(request, firewall)
registry.register(base, "GET", index)
registry.register(f"{base}/options", "GET", options_get)
registry.register(f"{base}/options", "PUT", options_put)
registry.register(f"{base}/rules", "GET", rules_list)
registry.register(f"{base}/rules", "POST", rules_create)
registry.register(f"{base}/rules/{{pos}}", "GET", rule_get)
registry.register(f"{base}/rules/{{pos}}", "PUT", rule_update)
registry.register(f"{base}/rules/{{pos}}", "DELETE", rule_delete)
registry.register(f"{base}/aliases", "GET", aliases_list)
registry.register(f"{base}/aliases", "POST", aliases_create)
registry.register(f"{base}/aliases/{{name}}", "GET", aliases_get)
registry.register(f"{base}/aliases/{{name}}", "PUT", aliases_update)
registry.register(f"{base}/aliases/{{name}}", "DELETE", aliases_delete)
registry.register(f"{base}/ipset", "GET", ipset_list)
registry.register(f"{base}/ipset", "POST", ipset_create)
registry.register(f"{base}/ipset/{{name}}", "GET", ipset_get)
registry.register(f"{base}/ipset/{{name}}", "DELETE", ipset_delete)
registry.register(f"{base}/ipset/{{name}}", "POST", ipset_entry_create)
registry.register(f"{base}/ipset/{{name}}/{{cidr}}", "GET", ipset_entry_get)
registry.register(f"{base}/ipset/{{name}}/{{cidr}}", "PUT", ipset_entry_update)
registry.register(f"{base}/ipset/{{name}}/{{cidr}}", "DELETE", ipset_entry_delete)
registry.register(f"{base}/refs", "GET", refs_list)
registry.register(f"{base}/log", "GET", log_list)
if include_macros:
registry.register(f"{base}/macros", "GET", macros_list)
if include_groups:
registry.register(f"{base}/groups", "GET", groups_list)
registry.register(f"{base}/groups", "POST", groups_create)
registry.register(f"{base}/groups/{{group}}", "GET", group_rules)
registry.register(f"{base}/groups/{{group}}", "POST", group_rule_create)
registry.register(f"{base}/groups/{{group}}", "DELETE", group_delete)
registry.register(f"{base}/groups/{{group}}/{{pos}}", "GET", group_rule_get)
registry.register(f"{base}/groups/{{group}}/{{pos}}", "PUT", group_rule_update)
registry.register(f"{base}/groups/{{group}}/{{pos}}", "DELETE", group_rule_delete)
register_scope(
"/cluster/firewall",
lambda _payload: "cluster",
include_macros=True,
include_groups=True,
)
register_scope(
"/nodes/{node}/firewall",
lambda payload: f"node:{payload['node']}",
require_node_name=True,
)
register_scope(
"/nodes/{node}/qemu/{vmid}/firewall",
lambda payload: f"qemu:{payload['node']}:{payload['vmid']}",
require_node_name=True,
)
register_scope(
"/nodes/{node}/lxc/{vmid}/firewall",
lambda payload: f"lxc:{payload['node']}:{payload['vmid']}",
require_node_name=True,
)
+350
View File
@@ -0,0 +1,350 @@
"""High availability semantic handlers."""
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
cluster_metadata,
database,
save_cluster_metadata,
state,
subdirs,
values,
)
from app.simulation.seed import CLUSTER_ID, stable_id
def _ha_groups(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
groups = metadata.get("ha_groups", {})
if not isinstance(groups, dict):
return {}
return {str(key): dict(value) for key, value in groups.items() if isinstance(value, dict)}
def register_ha_handlers(registry: HandlerRegistry) -> None:
async def ha_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("groups", "resources", "rules", "status")
async def ha_resources(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await database(_request).pool.fetch(
"""SELECT r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind='ha' ORDER BY r.external_id"""
)
result: list[dict[str, Any]] = []
for row in rows:
payload = state(row["state"])
sid = str(row["external_id"])
result.append(
{
"sid": sid,
"type": "vm" if sid.startswith("vm:") else "ct",
"state": payload.get("state", "started"),
"group": payload.get("group"),
"node": str(row["node"]),
"max_relocate": payload.get("max_relocate", 1),
"max_restart": payload.get("max_restart", 1),
}
)
return result
async def ha_resource_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
sid = str(values(inputs)["sid"])
items = await ha_resources(request, inputs)
for item in items:
if item["sid"] == sid:
return item
raise ApiError(404, "HA resource does not exist")
async def ha_resource_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
sid = str(payload["sid"])
group = str(payload.get("group") or "")
exists = await database(request).pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources WHERE kind='ha' AND external_id=$1)""",
sid,
)
if exists:
raise ApiError(409, "HA resource already exists")
guest_kind, _, guest_id = sid.partition(":")
if guest_kind not in {"vm", "ct"} or not guest_id.isdigit():
raise ApiError(400, "invalid HA resource sid")
resource_kind = "qemu" if guest_kind == "vm" else "lxc"
guest = await database(request).pool.fetchrow(
"""SELECT r.id, n.name FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind=$1 AND r.external_id=$2""",
resource_kind,
guest_id,
)
if guest is None:
raise ApiError(404, "guest does not exist")
node = await database(request).pool.fetchrow(
"SELECT id FROM nodes WHERE name=$1",
str(guest["name"]),
)
if node is None:
raise ApiError(404, "node does not exist")
ha_state = {
"state": str(payload.get("state") or "started"),
"group": group or None,
"max_relocate": int(payload.get("max_relocate") or 1),
"max_restart": int(payload.get("max_restart") or 1),
}
await database(request).pool.execute(
"""INSERT INTO resources(id, node_id, cluster_id, kind, external_id, state, metadata)
VALUES($1, $2, $3, 'ha', $4, $5::jsonb, '{}'::jsonb)""",
stable_id(f"ha:{sid}"),
node["id"],
CLUSTER_ID,
sid,
json.dumps(ha_state, sort_keys=True),
)
async def ha_resource_update(request: Request, inputs: dict[str, Any]) -> None:
sid = str(values(inputs)["sid"])
payload = values(inputs)
row = await database(request).pool.fetchrow(
"SELECT id, state FROM resources WHERE kind='ha' AND external_id=$1",
sid,
)
if row is None:
raise ApiError(404, "HA resource does not exist")
current = state(row["state"])
updated = {
**current,
**{
key: value
for key, value in payload.items()
if key not in {"sid", "delete", "digest"}
},
}
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, updated_at=now() WHERE id=$1",
row["id"],
json.dumps(updated, sort_keys=True),
)
async def ha_resource_delete(request: Request, inputs: dict[str, Any]) -> None:
sid = str(values(inputs)["sid"])
status = await database(request).pool.execute(
"DELETE FROM resources WHERE kind='ha' AND external_id=$1",
sid,
)
if status != "DELETE 1":
raise ApiError(404, "HA resource does not exist")
async def ha_groups(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
configured = _ha_groups(metadata)
result = [
{
"group": group_id,
"nodes": str(payload.get("nodes", "")),
"nofailback": int(payload.get("nofailback", 0)),
"restricted": int(payload.get("restricted", 0)),
"type": "group",
"comment": payload.get("comment", ""),
}
for group_id, payload in sorted(configured.items())
]
if result:
return result
rows = await database(request).pool.fetch(
"""SELECT DISTINCT state->>'group' AS group_id
FROM resources WHERE kind='ha' AND state ? 'group'
ORDER BY 1"""
)
node_names = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
nodes = ",".join(str(row["name"]) for row in node_names) or "pve01"
return [
{
"group": str(row["group_id"]),
"nodes": nodes,
"nofailback": 0,
"restricted": 0,
"type": "group",
}
for row in rows
if row["group_id"]
]
async def ha_group_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
group = str(values(inputs)["group"])
for item in await ha_groups(request, inputs):
if item["group"] == group:
return item
raise ApiError(404, "HA group does not exist")
async def ha_group_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
group = str(payload["group"])
metadata = await cluster_metadata(request)
groups = _ha_groups(metadata)
if group in groups:
raise ApiError(409, "HA group already exists")
groups[group] = {
"nodes": str(payload.get("nodes") or ""),
"nofailback": int(payload.get("nofailback") or 0),
"restricted": int(payload.get("restricted") or 0),
"comment": str(payload.get("comment") or ""),
}
metadata["ha_groups"] = groups
await save_cluster_metadata(request, metadata)
async def ha_group_update(request: Request, inputs: dict[str, Any]) -> None:
group = str(values(inputs)["group"])
metadata = await cluster_metadata(request)
groups = _ha_groups(metadata)
if group not in groups:
raise ApiError(404, "HA group does not exist")
payload = values(inputs)
groups[group] = {
**groups[group],
**{
key: value
for key, value in payload.items()
if key not in {"group", "delete", "digest"}
},
}
metadata["ha_groups"] = groups
await save_cluster_metadata(request, metadata)
async def ha_group_delete(request: Request, inputs: dict[str, Any]) -> None:
group = str(values(inputs)["group"])
metadata = await cluster_metadata(request)
groups = _ha_groups(metadata)
if group not in groups:
raise ApiError(404, "HA group does not exist")
del groups[group]
metadata["ha_groups"] = groups
await save_cluster_metadata(request, metadata)
async def ha_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("current", "manager_status")
async def ha_status_current(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"""SELECT count(*) FILTER (WHERE state->>'state' = 'started') AS started,
count(*) AS total
FROM resources WHERE kind='ha'"""
)
master = await database(request).pool.fetchval(
"SELECT name FROM nodes WHERE status='online' ORDER BY name LIMIT 1"
)
metadata = await cluster_metadata(request)
ha = metadata.get("ha", {}) if isinstance(metadata.get("ha"), dict) else {}
return {
"quorate": 1,
"mode": "active" if ha.get("armed", True) else "disabled",
"master_node": str(master or "pve01"),
"ha_started": int(row["started"] or 0),
"ha_total": int(row["total"] or 0),
"armed": 1 if ha.get("armed", True) else 0,
}
async def ha_manager_status(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
metadata = await cluster_metadata(request)
ha = metadata.get("ha", {}) if isinstance(metadata.get("ha"), dict) else {}
armed = bool(ha.get("armed", True))
return {
"manager_status": "active" if armed else "disabled",
"quorum": "OK",
"armed": 1 if armed else 0,
}
async def ha_rules(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
rules = metadata.get("ha_rules")
if isinstance(rules, list) and rules:
return [dict(item) for item in rules if isinstance(item, dict)]
defaults = [
{"rule": "node-fencing", "type": "node", "action": "restart"},
{"rule": "service-ha", "type": "resource", "action": "failover"},
]
metadata["ha_rules"] = defaults
await save_cluster_metadata(request, metadata)
return list(defaults)
async def ha_relocate(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
sid = str(payload["sid"])
target = str(payload.get("node") or payload.get("target") or "")
if not target:
raise ApiError(400, "parameter verification failed - target node missing")
ha_row = await database(request).pool.fetchrow(
"SELECT id, state FROM resources WHERE kind='ha' AND external_id=$1",
sid,
)
if ha_row is None:
raise ApiError(404, "HA resource does not exist")
node = await database(request).pool.fetchrow(
"SELECT id, name FROM nodes WHERE name=$1",
target,
)
if node is None:
raise ApiError(404, "node does not exist")
guest_kind, _, guest_id = sid.partition(":")
resource_kind = "qemu" if guest_kind == "vm" else "lxc"
await database(request).pool.execute(
"UPDATE resources SET node_id=$2, updated_at=now() WHERE kind='ha' AND external_id=$1",
sid,
node["id"],
)
await database(request).pool.execute(
"""UPDATE resources SET node_id=$3, updated_at=now()
WHERE kind=$1 AND external_id=$2""",
resource_kind,
guest_id,
node["id"],
)
current = state(ha_row["state"])
current["node"] = target
current["state"] = current.get("state") or "started"
await database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, updated_at=now() WHERE id=$1",
ha_row["id"],
json.dumps(current, sort_keys=True),
)
async def ha_migrate(request: Request, inputs: dict[str, Any]) -> None:
await ha_relocate(request, inputs)
async def ha_arm(request: Request, _inputs: dict[str, Any]) -> None:
metadata = await cluster_metadata(request)
ha = dict(metadata.get("ha") or {})
ha["armed"] = True
metadata["ha"] = ha
await save_cluster_metadata(request, metadata)
async def ha_disarm(request: Request, _inputs: dict[str, Any]) -> None:
metadata = await cluster_metadata(request)
ha = dict(metadata.get("ha") or {})
ha["armed"] = False
metadata["ha"] = ha
await save_cluster_metadata(request, metadata)
registry.register("/cluster/ha", "GET", ha_index)
registry.register("/cluster/ha/groups", "GET", ha_groups)
registry.register("/cluster/ha/groups", "POST", ha_group_create)
registry.register("/cluster/ha/groups/{group}", "GET", ha_group_get)
registry.register("/cluster/ha/groups/{group}", "PUT", ha_group_update)
registry.register("/cluster/ha/groups/{group}", "DELETE", ha_group_delete)
registry.register("/cluster/ha/resources", "GET", ha_resources)
registry.register("/cluster/ha/resources", "POST", ha_resource_create)
registry.register("/cluster/ha/resources/{sid}", "GET", ha_resource_get)
registry.register("/cluster/ha/resources/{sid}", "PUT", ha_resource_update)
registry.register("/cluster/ha/resources/{sid}", "DELETE", ha_resource_delete)
registry.register("/cluster/ha/status", "GET", ha_status)
registry.register("/cluster/ha/status/current", "GET", ha_status_current)
registry.register("/cluster/ha/status/manager_status", "GET", ha_manager_status)
registry.register("/cluster/ha/rules", "GET", ha_rules)
registry.register("/cluster/ha/resources/{sid}/migrate", "POST", ha_migrate)
registry.register("/cluster/ha/resources/{sid}/relocate", "POST", ha_relocate)
registry.register("/cluster/ha/status/arm-ha", "POST", ha_arm)
registry.register("/cluster/ha/status/disarm-ha", "POST", ha_disarm)
+59
View File
@@ -0,0 +1,59 @@
"""Legacy Proxmox path aliases for older contract snapshots."""
from __future__ import annotations
from app.api.registry import HandlerRegistry
def register_legacy_aliases(registry: HandlerRegistry) -> None:
"""Register older-path synonyms onto already-registered handlers when present."""
def alias(old_path: str, old_verb: str, new_path: str, new_verb: str | None = None) -> None:
verb = (new_verb or old_verb).upper()
handler = registry.get(new_path, verb)
if handler is None:
return
if registry.get(old_path, old_verb) is not None:
return
registry.register(old_path, old_verb.upper(), handler)
alias("/access/tfa", "POST", "/access/tfa/{userid}", "POST")
alias("/access/tfa", "PUT", "/access/tfa/{userid}/{id}", "PUT")
alias("/cluster/backupinfo", "GET", "/cluster/backup-info", "GET")
alias(
"/cluster/backupinfo/not_backed_up",
"GET",
"/cluster/backup-info/not-backed-up",
"GET",
)
alias("/nodes/{node}/ceph/config", "GET", "/nodes/{node}/ceph/cfg/raw", "GET")
alias("/nodes/{node}/ceph/configdb", "GET", "/nodes/{node}/ceph/cfg/db", "GET")
alias("/nodes/{node}/ceph/disks", "GET", "/nodes/{node}/ceph/osd", "GET")
alias("/nodes/{node}/ceph/flags", "GET", "/cluster/ceph/flags", "GET")
alias("/nodes/{node}/ceph/flags/{flag}", "POST", "/cluster/ceph/flags/{flag}", "PUT")
alias("/nodes/{node}/ceph/flags/{flag}", "DELETE", "/cluster/ceph/flags/{flag}", "PUT")
alias("/nodes/{node}/ceph/pools", "GET", "/nodes/{node}/ceph/pool", "GET")
alias("/nodes/{node}/ceph/pools", "POST", "/nodes/{node}/ceph/pool", "POST")
alias("/nodes/{node}/ceph/pools/{name}", "GET", "/nodes/{node}/ceph/pool/{name}", "GET")
alias("/nodes/{node}/ceph/pools/{name}", "PUT", "/nodes/{node}/ceph/pool/{name}", "PUT")
alias(
"/nodes/{node}/ceph/pools/{name}",
"DELETE",
"/nodes/{node}/ceph/pool/{name}",
"DELETE",
)
alias("/nodes/{node}/cpu", "GET", "/nodes/{node}/capabilities/qemu/cpu", "GET")
alias(
"/nodes/{node}/hardware/pci/{pciid}",
"GET",
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}",
"GET",
)
alias(
"/nodes/{node}/hardware/pci/{pciid}/mdev",
"GET",
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}/mdev",
"GET",
)
alias("/nodes/{node}/scan/glusterfs", "GET", "/nodes/{node}/scan/nfs", "GET")
alias("/nodes/{node}/scan/usb", "GET", "/nodes/{node}/hardware/usb", "GET")
+574
View File
@@ -0,0 +1,574 @@
"""Persistent LXC semantic handlers."""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.handlers.common import (
disk_size_bytes,
replace_disk_size,
require_node,
resize_size_bytes,
subdirs,
)
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
def _database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def _values(inputs: dict[str, Any]) -> dict[str, Any]:
return cast(dict[str, Any], inputs["values"])
def _state(value: object) -> dict[str, Any]:
if isinstance(value, str):
return cast(dict[str, Any], json.loads(value))
return dict(cast(Mapping[str, Any], value))
def register_lxc_handlers(registry: HandlerRegistry) -> None:
async def lxc_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(_values(inputs)["node"])
rows = await _database(request).pool.fetch(
"""SELECT r.external_id::integer AS vmid, r.state
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='lxc' ORDER BY r.external_id::integer""",
node,
)
return [{"vmid": int(row["vmid"]), **_state(row["state"])} for row in rows]
async def lxc_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
row = await _lxc_resource(request, node, vmid)
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
async def lxc_current(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await lxc_config(request, inputs)
async def lxc_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await lxc_config(request, inputs)
async def mutate(operation: str, request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "container does not exist")
current = str(_state(row["state"]).get("status", "stopped"))
try:
plan_transition(VmState(current), operation)
except (InvalidTransitionError, ValueError) as error:
raise ApiError(409, f"cannot {operation} container while it is {current}") from error
upid = str(Upid.allocate(node, f"pct{operation}", vmid, str(request.state.principal)))
try:
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=f"lxc-{operation}",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
resource_key=f"lxc:{vmid}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def start(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("start", request, inputs)
async def stop(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("stop", request, inputs)
async def shutdown(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("shutdown", request, inputs)
async def reboot(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("reboot", request, inputs)
async def suspend(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("suspend", request, inputs)
async def resume(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("resume", request, inputs)
async def create(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), int(values["vmid"])
database = _database(request)
if not await database.pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", node
):
raise ApiError(404, "node does not exist")
if await database.pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
str(vmid),
):
raise ApiError(409, "VMID already exists")
config = {
key: value
for key, value in values.items()
if key not in {"node", "vmid", "force", "start", "ostemplate"}
}
if "ostemplate" in values:
config["ostemplate"] = values["ostemplate"]
return await _create_task(
request,
node=node,
vmid=str(vmid),
task_type="lxc-create",
payload={
"node": node,
"vmid": vmid,
"config": config,
"start": bool(values.get("start")),
},
)
async def update(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.version, r.state, c.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN containers c ON c.resource_id=r.id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "container does not exist")
control = {"node", "vmid", "digest", "delete", "revert", "skiplock"}
provided = frozenset(str(item) for item in inputs.get("provided", values))
changes = {
key: value for key, value in values.items() if key in provided and key not in control
}
delete = str(values.get("delete", "")) if "delete" in provided else ""
state = _state(row["state"])
config = _state(row["config"])
state.update(changes)
config.update(changes)
for key in delete.split(","):
if key:
state.pop(key, None)
config.pop(key, None)
status = await database.pool.execute(
"""UPDATE resources SET state=$3::jsonb, version=version+1,
updated_at=now() WHERE id=$1 AND version=$2""",
row["id"],
row["version"],
json.dumps(state, sort_keys=True),
)
if status != "UPDATE 1":
raise ApiError(409, "configuration changed concurrently")
await database.pool.execute(
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
row["id"],
json.dumps(config, sort_keys=True),
)
async def delete(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "container does not exist")
if str(_state(row["state"]).get("status")) != "stopped":
raise ApiError(409, "cannot delete a running container")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="lxc-delete",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
)
async def snapshot_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
rows = await _database(request).pool.fetch(
"""SELECT name, parent_name, description, created_at FROM snapshots
WHERE resource_id=$1 ORDER BY created_at, name""",
resource["id"],
)
return [
{
"name": row["name"],
"parent": row["parent_name"],
"description": row["description"] or "",
"snaptime": int(row["created_at"].timestamp()),
}
for row in rows
]
async def snapshot_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
row = await _snapshot(request, values)
state = _state(row["state"])
return {
"name": row["name"],
"parent": row["parent_name"],
"description": row["description"] or "",
"snaptime": int(row["created_at"].timestamp()),
**state,
}
async def snapshot_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
row = await _snapshot(request, _values(inputs))
return {"description": row["description"] or "", **_state(row["state"])}
async def snapshot_update(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
row = await _snapshot(request, values)
await _database(request).pool.execute(
"UPDATE snapshots SET description=$2 WHERE id=$1",
row["id"],
str(values.get("description", "")),
)
async def snapshot_task(operation: str, request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, snapname = (
str(values["node"]),
str(values["vmid"]),
str(values["snapname"]),
)
resource = await _lxc_resource(request, node, vmid)
if operation == "snapshot-create":
exists = await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM snapshots WHERE resource_id=$1 AND name=$2)",
resource["id"],
snapname,
)
if exists:
raise ApiError(409, "snapshot already exists")
else:
await _snapshot(request, values)
return await _create_task(
request,
node=node,
vmid=vmid,
task_type=f"lxc-{operation}",
payload={
"node": node,
"vmid": vmid,
"resource_id": str(resource["id"]),
"snapname": snapname,
"description": str(values.get("description", "")),
},
)
async def snapshot_create(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-create", request, inputs)
async def snapshot_delete(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-delete", request, inputs)
async def snapshot_rollback(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-rollback", request, inputs)
async def clone(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, newid = str(values["node"]), str(values["vmid"]), str(values["newid"])
source = await _lxc_resource(request, node, vmid)
if await _database(request).pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
newid,
):
raise ApiError(409, "VMID already exists")
target = str(values.get("target") or node)
if not await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
):
raise ApiError(404, "target node does not exist")
return await _create_task(
request,
node=target,
vmid=newid,
task_type="lxc-clone",
payload={
"source_resource_id": str(source["id"]),
"source_vmid": vmid,
"node": target,
"vmid": int(newid),
"name": values.get("hostname") or values.get("name"),
"full": bool(values.get("full", False)),
},
)
async def migrate_preconditions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
target = str(target)
exists = await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
)
if not exists:
raise ApiError(404, "target node does not exist")
return {"local_disks": [], "local_resources": [], "running": False}
async def migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
target = str(target)
resource = await _lxc_resource(request, node, vmid)
if target == node:
raise ApiError(400, "target node is the same as source node")
await migrate_preconditions(request, inputs)
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="lxc-migrate",
payload={
"resource_id": str(resource["id"]),
"node": node,
"target": target,
"vmid": vmid,
"online": bool(values.get("online", False)),
},
)
async def remote_migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
target_endpoint = str(values.get("target-endpoint") or values.get("target_endpoint") or "")
target = str(values.get("target") or "")
if not target_endpoint:
raise ApiError(400, "parameter target-endpoint is required")
if not target:
raise ApiError(400, "parameter target is required")
node, vmid = str(values["node"]), str(values["vmid"])
resource = await _lxc_resource(request, node, vmid)
if target == node:
raise ApiError(400, "target node is the same as source node")
if not await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
):
raise ApiError(404, "target node does not exist")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="lxc-remote-migrate",
payload={
"resource_id": str(resource["id"]),
"node": node,
"target": target,
"vmid": vmid,
"target-endpoint": target_endpoint,
"online": bool(values.get("online", False)),
},
)
async def pending(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
config = _state(resource["config"])
changes = cast(Mapping[str, Any], state.get("pending", {}))
return [
{"key": key, "value": str(config.get(key, "")), "pending": str(value)}
for key, value in sorted(changes.items())
]
async def lxc_feature(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
await _lxc_resource(request, str(payload["node"]), str(payload["vmid"]))
return {
"hasFeature": {
"snapshot": 1,
"clone": 1,
"copy": 1,
"template": 1,
"move_volume": 1,
}
}
async def lxc_resize(request: Request, inputs: dict[str, Any]) -> None:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
disk = str(payload.get("disk") or "rootfs")
resource = await _lxc_resource(request, node, vmid)
config = _state(resource["config"])
if disk not in config:
raise ApiError(400, f"disk {disk} does not exist")
try:
current = disk_size_bytes(str(config[disk]))
size = resize_size_bytes(str(payload["size"]), current)
except ValueError as error:
raise ApiError(400, str(error)) from error
config[disk] = replace_disk_size(str(config[disk]), size)
await _database(request).pool.execute(
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
resource["id"],
json.dumps(config, sort_keys=True),
)
await _database(request).pool.execute(
"""UPDATE resources SET state=state || $2::jsonb, version=version+1,
updated_at=now() WHERE id=$1""",
resource["id"],
json.dumps({disk: config[disk]}, sort_keys=True),
)
async def lxc_template(request: Request, inputs: dict[str, Any]) -> None:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
resource = await _lxc_resource(request, node, vmid)
state = _state(resource["state"])
if state.get("status") != "stopped":
raise ApiError(409, "container must be stopped to convert to template")
await _database(request).pool.execute(
"UPDATE containers SET template=true WHERE resource_id=$1",
resource["id"],
)
state["template"] = True
await _database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb WHERE id=$1",
resource["id"],
json.dumps(state, sort_keys=True),
)
async def lxc_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
await require_node(request, node)
await _lxc_resource(request, node, vmid)
return subdirs(
"clone",
"config",
"feature",
"firewall",
"migrate",
"pending",
"resize",
"snapshot",
"status",
"template",
)
registry.register("/nodes/{node}/lxc", "GET", lxc_list)
registry.register("/nodes/{node}/lxc", "POST", create)
registry.register("/nodes/{node}/lxc/{vmid}", "GET", lxc_index)
registry.register("/nodes/{node}/lxc/{vmid}", "DELETE", delete)
registry.register("/nodes/{node}/lxc/{vmid}/config", "GET", lxc_config)
registry.register("/nodes/{node}/lxc/{vmid}/config", "PUT", update)
registry.register("/nodes/{node}/lxc/{vmid}/status", "GET", lxc_status)
registry.register("/nodes/{node}/lxc/{vmid}/status/current", "GET", lxc_current)
registry.register("/nodes/{node}/lxc/{vmid}/status/start", "POST", start)
registry.register("/nodes/{node}/lxc/{vmid}/status/stop", "POST", stop)
registry.register("/nodes/{node}/lxc/{vmid}/status/shutdown", "POST", shutdown)
registry.register("/nodes/{node}/lxc/{vmid}/status/reboot", "POST", reboot)
registry.register("/nodes/{node}/lxc/{vmid}/status/suspend", "POST", suspend)
registry.register("/nodes/{node}/lxc/{vmid}/status/resume", "POST", resume)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot", "GET", snapshot_list)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot", "POST", snapshot_create)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}", "GET", snapshot_get)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}", "DELETE", snapshot_delete)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/config", "GET", snapshot_config)
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/config", "PUT", snapshot_update)
registry.register(
"/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/rollback", "POST", snapshot_rollback
)
registry.register("/nodes/{node}/lxc/{vmid}/clone", "POST", clone)
registry.register("/nodes/{node}/lxc/{vmid}/migrate", "GET", migrate_preconditions)
registry.register("/nodes/{node}/lxc/{vmid}/migrate", "POST", migrate)
registry.register("/nodes/{node}/lxc/{vmid}/remote_migrate", "POST", remote_migrate)
registry.register("/nodes/{node}/lxc/{vmid}/pending", "GET", pending)
registry.register("/nodes/{node}/lxc/{vmid}/feature", "GET", lxc_feature)
registry.register("/nodes/{node}/lxc/{vmid}/resize", "PUT", lxc_resize)
registry.register("/nodes/{node}/lxc/{vmid}/template", "POST", lxc_template)
from app.handlers.lxc_extra import register_lxc_extra_handlers
register_lxc_extra_handlers(registry)
async def _create_task(
request: Request,
*,
node: str,
vmid: str,
task_type: str,
payload: dict[str, Any],
) -> str:
database = _database(request)
worker_type = {
"lxc-create": "pctcreate",
"lxc-delete": "pctdestroy",
"lxc-snapshot-create": "pctsnapshot",
"lxc-snapshot-delete": "pctdelsnapshot",
"lxc-snapshot-rollback": "pctrollback",
"lxc-clone": "pctclone",
"lxc-migrate": "pctmigrate",
"lxc-remote-migrate": "pctremote",
}[task_type]
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
try:
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=task_type,
payload=payload,
resource_key=f"lxc:{vmid}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def _lxc_resource(request: Request, node: str, vmid: str) -> Any:
row = await _database(request).pool.fetchrow(
"""SELECT r.id, r.state, c.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN containers c ON c.resource_id=r.id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "container does not exist")
return row
async def _snapshot(request: Request, values: dict[str, Any]) -> Any:
row = await _database(request).pool.fetchrow(
"""SELECT s.* FROM snapshots s
JOIN resources r ON r.id=s.resource_id JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2 AND s.name=$3""",
str(values["node"]),
str(values["vmid"]),
str(values["snapname"]),
)
if row is None:
raise ApiError(404, "snapshot does not exist")
return row
+152
View File
@@ -0,0 +1,152 @@
"""Remaining LXC console / RRD / volume helpers with durable state."""
from __future__ import annotations
import json
import secrets
from typing import Any, cast
from fastapi import Request
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.handlers.lxc import _database, _lxc_resource, _state, _values
from app.security.auth import issue_ticket
def _settings(request: Request) -> Settings:
return cast(Settings, request.app.state.settings)
async def _save_state(request: Request, resource_id: Any, state: dict[str, Any]) -> None:
await _database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1, updated_at=now() WHERE id=$1",
resource_id,
json.dumps(state, sort_keys=True),
)
async def _save_config(request: Request, resource_id: Any, config: dict[str, Any]) -> None:
await _database(request).pool.execute(
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
resource_id,
json.dumps(config, sort_keys=True),
)
def register_lxc_extra_handlers(registry: HandlerRegistry) -> None:
async def interfaces(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
ifaces = state.setdefault(
"interfaces",
[{"name": "eth0", "hwaddr": "02:00:00:00:00:11", "inet": "192.0.2.20/24"}],
)
await _save_state(request, resource["id"], state)
return list(ifaces) if isinstance(ifaces, list) else []
async def move_volume(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
volume = str(values.get("volume") or values.get("disk") or "rootfs")
storage = str(values.get("storage") or "local-lvm")
config = _state(resource["config"])
current = str(config.get(volume) or "")
if current:
# rewrite storage prefix when present
rest = current.split(":", 1)[1] if ":" in current else current
config[volume] = f"{storage}:{rest}"
await _save_config(request, resource["id"], config)
state = _state(resource["state"])
moves = state.setdefault("volume_moves", [])
if not isinstance(moves, list):
moves = state["volume_moves"] = []
moves.append({"volume": volume, "storage": storage})
await _save_state(request, resource["id"], state)
return f"UPID:{values['node']}:lxc-move-volume:{values['vmid']}"
async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
rrd_state = state.setdefault("rrd", {"filename": f"pve-ct-{values['vmid']}.rrd"})
await _save_state(request, resource["id"], state)
return dict(rrd_state)
async def rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
series = state.setdefault(
"rrddata",
[
{"time": 1_700_000_000, "cpu": 0.02, "mem": 64 * 1024 * 1024},
{"time": 1_700_000_060, "cpu": 0.03, "mem": 66 * 1024 * 1024},
],
)
await _save_state(request, resource["id"], state)
return list(series)
async def _console(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
key = _settings(request).ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(str(request.state.principal), key)
port = 6900 + int(values["vmid"]) % 1000
state = _state(resource["state"])
consoles = state.setdefault("consoles", {})
payload = {
"type": kind,
"port": port,
"ticket": ticket,
"upid": (
f"UPID:{values['node']}:{secrets.token_hex(4)}:"
f"{kind}:{values['vmid']}:{request.state.principal}:"
),
"user": str(request.state.principal),
}
consoles[kind] = {k: v for k, v in payload.items() if k != "ticket"}
await _save_state(request, resource["id"], state)
return payload
async def vncproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console(request, inputs, "vnc")
async def spiceproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console(request, inputs, "spice")
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console(request, inputs, "term")
async def mtunnel(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console(request, inputs, "mtunnel")
async def _ws(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
values = _values(inputs)
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
console = state.get("consoles", {}).get(kind) or {"port": 6900}
key = _settings(request).ticket_signing_key.get_secret_value().encode()
return {
"port": console.get("port", 6900),
"ticket": issue_ticket(str(request.state.principal), key),
}
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _ws(request, inputs, "vnc")
async def mtunnelwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _ws(request, inputs, "mtunnel")
base = "/nodes/{node}/lxc/{vmid}"
registry.register(f"{base}/interfaces", "GET", interfaces)
registry.register(f"{base}/move_volume", "POST", move_volume)
registry.register(f"{base}/rrd", "GET", rrd)
registry.register(f"{base}/rrddata", "GET", rrddata)
registry.register(f"{base}/vncproxy", "POST", vncproxy)
registry.register(f"{base}/spiceproxy", "POST", spiceproxy)
registry.register(f"{base}/termproxy", "POST", termproxy)
registry.register(f"{base}/mtunnel", "POST", mtunnel)
registry.register(f"{base}/vncwebsocket", "GET", vncwebsocket)
registry.register(f"{base}/mtunnelwebsocket", "GET", mtunnelwebsocket)
+99
View File
@@ -0,0 +1,99 @@
"""Cluster resource mapping handlers (dir/pci/usb)."""
from __future__ import annotations
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
def _mappings(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault("mapping", {"dir": {}, "pci": {}, "usb": {}})
if not isinstance(current, dict):
current = {"dir": {}, "pci": {}, "usb": {}}
metadata["mapping"] = current
for kind in ("dir", "pci", "usb"):
current.setdefault(kind, {})
return current
def register_mapping_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("dir", "pci", "usb")
def register_kind(kind: str) -> None:
base = f"/cluster/mapping/{kind}"
async def list_items(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
check_node = values(inputs).get("check-node")
result = [{"id": key, **item} for key, item in sorted(store.items())]
if check_node:
for item in result:
item["checks"] = {str(check_node): "OK"}
return result
async def create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
item_id = str(payload["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
if item_id in store:
raise ApiError(400, f"{kind} mapping '{item_id}' already exists")
store[item_id] = {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
}
await save_cluster_metadata(request, metadata)
async def get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
item_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
if item_id not in store:
raise ApiError(404, f"{kind} mapping does not exist")
return {"id": item_id, **store[item_id]}
async def update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
item_id = str(payload["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
if item_id not in store:
raise ApiError(404, f"{kind} mapping does not exist")
current = dict(store[item_id])
for key in [
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
]:
current.pop(key, None)
for key, value in payload.items():
if key in {"id", "delete", "digest"}:
continue
current[key] = value
current["id"] = item_id
store[item_id] = current
await save_cluster_metadata(request, metadata)
async def delete(request: Request, inputs: dict[str, Any]) -> None:
item_id = str(values(inputs)["id"])
metadata = await cluster_metadata(request)
store = _mappings(metadata)[kind]
if item_id not in store:
raise ApiError(404, f"{kind} mapping does not exist")
del store[item_id]
await save_cluster_metadata(request, metadata)
registry.register(base, "GET", list_items)
registry.register(base, "POST", create)
registry.register(f"{base}/{{id}}", "GET", get)
registry.register(f"{base}/{{id}}", "PUT", update)
registry.register(f"{base}/{{id}}", "DELETE", delete)
registry.register("/cluster/mapping", "GET", index)
register_kind("dir")
register_kind("pci")
register_kind("usb")
+421
View File
@@ -0,0 +1,421 @@
"""Node-level operational handlers (apt, network, disks, services)."""
from __future__ import annotations
import copy
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import (
database,
node_metadata,
require_node,
save_node_metadata,
subdirs,
values,
)
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
DEFAULT_NODE_OPS: dict[str, Any] = {
"network": [
{
"iface": "vmbr0",
"type": "bridge",
"active": 1,
"method": "static",
"address": "10.0.0.10/24",
},
{
"iface": "vmbr1",
"type": "bridge",
"active": 1,
"method": "static",
"address": "10.10.0.10/24",
},
{"iface": "eno1", "type": "eth", "active": 1, "method": "manual"},
],
"disks": {
"list": [
{
"devpath": "/dev/sda",
"size": 1_000_000_000_000,
"model": "SIM-DISK-01",
"serial": "SIM0001",
"gpt": 1,
},
{
"devpath": "/dev/sdb",
"size": 2_000_000_000_000,
"model": "SIM-SSD-01",
"serial": "SIM0002",
"gpt": 0,
},
],
"directory": [],
"lvm": [],
"lvmthin": [],
"zfs": [],
"smart": {},
},
"services": {
"pveproxy": {"state": "running", "enabled": 1},
"pvedaemon": {"state": "running", "enabled": 1},
"pvestatd": {"state": "running", "enabled": 1},
"corosync": {"state": "running", "enabled": 1},
},
"apt": {
"packages": [
{
"Package": "pve-manager",
"Version": "9.2.3",
"OldVersion": "9.2.2",
"Status": "upgradable",
},
{"Package": "libpve-common-perl", "Version": "9.0.3", "Status": "installed"},
],
"repositories": [
{
"path": "/etc/apt/sources.list.d/pve-enterprise.list",
"enabled": 1,
"types": "deb",
"uri": "http://download.proxmox.com/debian/pve",
"suites": "bookworm",
"components": "pve-no-subscription",
}
],
"update": {"status": "stopped", "exitstatus": "OK"},
"changelogs": {},
},
}
def default_node_ops() -> dict[str, Any]:
return copy.deepcopy(DEFAULT_NODE_OPS)
async def load_node_ops(request: Request, node: str) -> dict[str, Any]:
metadata = await node_metadata(request, node)
ops = metadata.get("ops")
if isinstance(ops, dict) and ops:
return ops
ops = default_node_ops()
metadata["ops"] = ops
await save_node_metadata(request, node, metadata)
return ops
async def save_node_ops(request: Request, node: str, ops: dict[str, Any]) -> None:
metadata = await node_metadata(request, node)
metadata["ops"] = ops
await save_node_metadata(request, node, metadata)
def register_node_ops_handlers(registry: HandlerRegistry) -> None:
async def apt_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("changelog", "repositories", "update", "versions")
async def apt_versions(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
packages = ops.get("apt", {}).get("packages", [])
return list(packages) if isinstance(packages, list) else []
async def apt_repositories(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
repositories = ops.get("apt", {}).get("repositories", [])
return list(repositories) if isinstance(repositories, list) else []
async def apt_changelog(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
name = str(values(inputs).get("name") or "pve-manager")
ops = await load_node_ops(request, node)
changelogs = ops.setdefault("apt", {}).setdefault("changelogs", {})
if name not in changelogs:
changelogs[name] = f"simulated changelog for {name}\n\n * emulator build\n"
await save_node_ops(request, node, ops)
return str(changelogs[name])
async def apt_update_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
update = ops.get("apt", {}).get("update", {"status": "stopped", "exitstatus": "OK"})
if isinstance(update, dict):
return dict(update)
return {"status": "stopped", "exitstatus": "OK"}
async def apt_update_start(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
apt = ops.setdefault("apt", {})
apt["update"] = {"status": "running", "exitstatus": ""}
await save_node_ops(request, node, ops)
return await _node_task(request, node=node, task_type="aptupdate", worker="aptupdate")
async def network_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
network = ops.get("network", [])
return [dict(item) for item in network] if isinstance(network, list) else []
async def network_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
iface = str(values(inputs)["iface"])
for item in await network_list(request, inputs):
if item.get("iface") == iface:
return item
raise ApiError(404, "interface does not exist")
async def network_mutate(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
payload = values(inputs)
ops = await load_node_ops(request, node)
network = list(ops.get("network") or [])
iface = payload.get("iface")
method = request.method.upper()
if method == "DELETE":
target = str(iface or "")
if not any(item.get("iface") == target for item in network):
raise ApiError(404, "interface does not exist")
ops["network"] = [item for item in network if item.get("iface") != target]
elif method == "POST":
name = str(iface or payload.get("iface") or "")
if not name:
raise ApiError(400, "parameter verification failed - 'iface' missing")
if any(item.get("iface") == name for item in network):
raise ApiError(400, f"interface '{name}' already exists")
entry = {
key: value
for key, value in payload.items()
if key not in {"node", "delete", "digest"}
}
entry["iface"] = name
entry.setdefault("type", "bridge")
entry.setdefault("active", 1)
network.append(entry)
ops["network"] = network
elif method == "PUT" and iface is not None:
name = str(iface)
found = False
updated: list[dict[str, Any]] = []
for item in network:
if item.get("iface") != name:
updated.append(item)
continue
found = True
merged = {
**item,
**{
key: value
for key, value in payload.items()
if key not in {"node", "iface", "delete", "digest"}
},
}
merged["iface"] = name
updated.append(merged)
if not found:
raise ApiError(404, "interface does not exist")
ops["network"] = updated
else:
ops["network_applied"] = True
await save_node_ops(request, node, ops)
async def disks_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("directory", "list", "lvm", "lvmthin", "smart", "zfs")
async def _disks(request: Request, node: str) -> dict[str, Any]:
ops = await load_node_ops(request, node)
disks = ops.setdefault("disks", default_node_ops()["disks"])
if not isinstance(disks, dict):
disks = default_node_ops()["disks"]
ops["disks"] = disks
await save_node_ops(request, node, ops)
return cast(dict[str, Any], disks)
async def disks_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
disks = await _disks(request, node)
items = disks.get("list", [])
return [dict(item) for item in items] if isinstance(items, list) else []
async def disks_smart(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
disk = str(values(inputs).get("disk") or "/dev/sda")
disks = await _disks(request, node)
smart = disks.setdefault("smart", {})
if disk not in smart:
smart[disk] = {
"health": "PASSED",
"type": "scsi",
"model": "SIM-DISK",
"serial": disk.rsplit("/", 1)[-1],
}
ops = await load_node_ops(request, node)
ops["disks"] = disks
await save_node_ops(request, node, ops)
return dict(smart[disk])
async def disks_collection(
request: Request, inputs: dict[str, Any], key: str
) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
disks = await _disks(request, node)
items = disks.get(key, [])
return [dict(item) for item in items] if isinstance(items, list) else []
async def disks_directory(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
return await disks_collection(request, inputs, "directory")
async def disks_lvm(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
return await disks_collection(request, inputs, "lvm")
async def disks_lvmthin(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
return await disks_collection(request, inputs, "lvmthin")
async def disks_zfs(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
return await disks_collection(request, inputs, "zfs")
async def disks_initgpt(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
if not disk:
raise ApiError(400, "parameter verification failed - 'disk' missing")
ops = await load_node_ops(request, node)
disks = ops.setdefault("disks", default_node_ops()["disks"])
items = list(disks.get("list") or [])
found = False
for item in items:
if item.get("devpath") == disk:
item["gpt"] = 1
found = True
break
if not found:
items.append(
{
"devpath": disk,
"size": 0,
"model": "SIM-DISK",
"serial": disk,
"gpt": 1,
}
)
disks["list"] = items
ops["disks"] = disks
await save_node_ops(request, node, ops)
async def disks_wipedisk(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
if not disk:
raise ApiError(400, "parameter verification failed - 'disk' missing")
ops = await load_node_ops(request, node)
disks = ops.setdefault("disks", default_node_ops()["disks"])
items = list(disks.get("list") or [])
for item in items:
if item.get("devpath") == disk:
item["wiped"] = 1
item["gpt"] = 0
break
else:
raise ApiError(404, "disk does not exist")
disks["list"] = items
smart = disks.setdefault("smart", {})
smart.pop(disk, None)
ops["disks"] = disks
await save_node_ops(request, node, ops)
async def services_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(values(inputs)["node"])
ops = await load_node_ops(request, node)
services = ops.get("services") or {}
return [{"subdir": name} for name in sorted(services)]
async def service_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
service = str(values(inputs)["service"])
ops = await load_node_ops(request, node)
services = ops.setdefault("services", {})
if service not in services:
services[service] = {"state": "stopped", "enabled": 0}
await save_node_ops(request, node, ops)
payload = dict(services[service])
payload["service"] = service
return payload
async def service_state(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await service_get(request, inputs)
async def service_action(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
service = str(values(inputs)["service"])
path = request.url.path.rstrip("/")
action = path.rsplit("/", 1)[-1]
ops = await load_node_ops(request, node)
services = ops.setdefault("services", {})
current = dict(services.get(service) or {"state": "stopped", "enabled": 0})
if action == "start":
current["state"] = "running"
current["enabled"] = 1
elif action == "stop":
current["state"] = "stopped"
elif action in {"restart", "reload"}:
current["state"] = "running"
current["enabled"] = 1
else:
raise ApiError(400, f"unknown service action: {action}")
services[service] = current
ops["services"] = services
await save_node_ops(request, node, ops)
return "OK"
registry.register("/nodes/{node}/apt", "GET", apt_index)
registry.register("/nodes/{node}/apt/versions", "GET", apt_versions)
registry.register("/nodes/{node}/apt/repositories", "GET", apt_repositories)
registry.register("/nodes/{node}/apt/changelog", "GET", apt_changelog)
registry.register("/nodes/{node}/apt/update", "GET", apt_update_status)
registry.register("/nodes/{node}/apt/update", "POST", apt_update_start)
registry.register("/nodes/{node}/network", "GET", network_list)
registry.register("/nodes/{node}/network", "POST", network_mutate)
registry.register("/nodes/{node}/network", "PUT", network_mutate)
registry.register("/nodes/{node}/network/{iface}", "GET", network_get)
registry.register("/nodes/{node}/network/{iface}", "PUT", network_mutate)
registry.register("/nodes/{node}/network/{iface}", "DELETE", network_mutate)
registry.register("/nodes/{node}/disks", "GET", disks_index)
registry.register("/nodes/{node}/disks/list", "GET", disks_list)
registry.register("/nodes/{node}/disks/smart", "GET", disks_smart)
registry.register("/nodes/{node}/disks/directory", "GET", disks_directory)
registry.register("/nodes/{node}/disks/lvm", "GET", disks_lvm)
registry.register("/nodes/{node}/disks/lvmthin", "GET", disks_lvmthin)
registry.register("/nodes/{node}/disks/zfs", "GET", disks_zfs)
registry.register("/nodes/{node}/disks/initgpt", "POST", disks_initgpt)
registry.register("/nodes/{node}/disks/wipedisk", "PUT", disks_wipedisk)
registry.register("/nodes/{node}/services", "GET", services_index)
registry.register("/nodes/{node}/services/{service}", "GET", service_get)
registry.register("/nodes/{node}/services/{service}/state", "GET", service_state)
registry.register("/nodes/{node}/services/{service}/start", "POST", service_action)
registry.register("/nodes/{node}/services/{service}/stop", "POST", service_action)
registry.register("/nodes/{node}/services/{service}/restart", "POST", service_action)
registry.register("/nodes/{node}/services/{service}/reload", "POST", service_action)
async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str:
from app.api.errors import ApiError
from app.db.primitives import ConflictError
pool = database(request).pool
upid = str(Upid.allocate(node, worker, "0", str(request.state.principal)))
try:
task = await TaskRepository(pool).create(
upid=upid,
task_type=task_type,
payload={"node": node},
resource_key=f"node:{node}",
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
+972
View File
@@ -0,0 +1,972 @@
"""Additional node-level handlers with durable ops persistence."""
from __future__ import annotations
import copy
import json
import secrets
import time
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, require_node, subdirs, values
from app.handlers.nodes import default_node_ops, load_node_ops, save_node_ops
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
DEFAULT_HARDWARE: dict[str, Any] = {
"pci": [
{
"id": "0000:00:1f.2",
"vendor_name": "Intel Corporation",
"device_name": "SATA Controller",
"iommugroup": 0,
},
{
"id": "0000:01:00.0",
"vendor_name": "NVIDIA Corporation",
"device_name": "GP102 [GeForce GTX 1080 Ti]",
"iommugroup": 1,
"mdev": 1,
},
],
"usb": [
{"busnum": 1, "devnum": 1, "level": 0, "port": "1", "prodid": "0002", "vendid": "1d6b"},
{"busnum": 2, "devnum": 2, "level": 1, "port": "2", "prodid": "5591", "vendid": "0781"},
],
"mdev": {
"0000:01:00.0": [
{"type": "nvidia-11", "available": 4, "description": "GRID profile"},
]
},
}
DEFAULT_SCAN: dict[str, list[dict[str, Any]]] = {
"cifs": [{"server": "files.local", "share": "backups"}],
"iscsi": [{"portal": "10.0.0.50:3260", "target": "iqn.2024-01.local:storage"}],
"lvm": [{"vg": "pve", "size": 500_000_000_000, "free": 100_000_000_000}],
"lvmthin": [{"lv": "data", "vg": "pve", "lv_size": 400_000_000_000}],
"nfs": [{"server": "nfs.local", "path": "/export/pve", "options": "vers=4"}],
"pbs": [{"server": "pbs.local", "datastore": "store1"}],
"zfs": [{"pool": "rpool", "name": "rpool/data", "size": 800_000_000_000}],
}
DEFAULT_SUBSCRIPTION: dict[str, Any] = {
"status": "notfound",
"message": "There is no subscription key",
"serverid": "SIMULATOR",
"sockets": 1,
"productname": "Proxmox VE",
"url": "https://www.proxmox.com/en/proxmox-virtual-environment/pricing",
}
DEFAULT_CONFIG: dict[str, Any] = {
"description": "Simulator node",
"startall-onboot-delay": 0,
"wakeonlan": "",
}
DEFAULT_DNS: dict[str, Any] = {
"search": "local",
"dns1": "1.1.1.1",
"dns2": "8.8.8.8",
"dns3": "",
}
DEFAULT_TIME: dict[str, Any] = {
"timezone": "UTC",
"time": 0,
"localtime": 0,
}
def _certificates(ops: dict[str, Any]) -> dict[str, Any]:
certs = ops.setdefault(
"certificates",
{
"custom": None,
"acme": {"account": "default", "domains": [], "certificate": None},
"info": [],
},
)
if not isinstance(certs, dict):
certs = {"custom": None, "acme": {}, "info": []}
ops["certificates"] = certs
certs.setdefault("acme", {"account": "default", "domains": [], "certificate": None})
certs.setdefault("info", [])
return certs
def _hardware(ops: dict[str, Any]) -> dict[str, Any]:
hardware = ops.get("hardware")
if not isinstance(hardware, dict) or not hardware:
hardware = copy.deepcopy(DEFAULT_HARDWARE)
ops["hardware"] = hardware
hardware.setdefault("pci", copy.deepcopy(DEFAULT_HARDWARE["pci"]))
hardware.setdefault("usb", copy.deepcopy(DEFAULT_HARDWARE["usb"]))
hardware.setdefault("mdev", copy.deepcopy(DEFAULT_HARDWARE["mdev"]))
return hardware
def _scan_cache(ops: dict[str, Any]) -> dict[str, Any]:
scan = ops.get("scan")
if not isinstance(scan, dict) or not scan:
scan = copy.deepcopy(DEFAULT_SCAN)
ops["scan"] = scan
for key, value in DEFAULT_SCAN.items():
scan.setdefault(key, copy.deepcopy(value))
return scan
def _subscription(ops: dict[str, Any]) -> dict[str, Any]:
subscription = ops.get("subscription")
if not isinstance(subscription, dict) or not subscription:
subscription = copy.deepcopy(DEFAULT_SUBSCRIPTION)
ops["subscription"] = subscription
return subscription
def _disk_items(ops: dict[str, Any], kind: str) -> list[dict[str, Any]]:
disks = ops.setdefault("disks", default_node_ops()["disks"])
if not isinstance(disks, dict):
disks = default_node_ops()["disks"]
ops["disks"] = disks
items = disks.setdefault(kind, [])
if not isinstance(items, list):
items = []
disks[kind] = items
return items
def _public_cert(entry: dict[str, Any] | None) -> dict[str, Any] | None:
if entry is None:
return None
return {key: value for key, value in entry.items() if key not in {"key", "private-key"}}
async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str:
from app.db.primitives import ConflictError
pool = database(request).pool
upid = str(Upid.allocate(node, worker, "0", str(request.state.principal)))
try:
task = await TaskRepository(pool).create(
upid=upid,
task_type=task_type,
payload={"node": node},
resource_key=f"node:{node}:{task_type}",
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def _set_guest_status(request: Request, node: str, status: str) -> None:
await database(request).pool.execute(
"""UPDATE resources AS r
SET state = jsonb_set(COALESCE(r.state, '{}'::jsonb), '{status}', to_jsonb($2::text), true),
updated_at=now()
WHERE r.node_id=(SELECT id FROM nodes WHERE name=$1) AND r.kind IN ('qemu', 'lxc')""",
node,
status,
)
async def _migrate_guests(request: Request, node: str, target: str) -> None:
target_row = await database(request).pool.fetchrow("SELECT id FROM nodes WHERE name=$1", target)
if target_row is None:
raise ApiError(404, "target node does not exist")
await database(request).pool.execute(
"""UPDATE resources SET node_id=$2, updated_at=now()
WHERE node_id=(SELECT id FROM nodes WHERE name=$1) AND kind IN ('qemu', 'lxc')""",
node,
target_row["id"],
)
def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
async def disks_create(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
name = str(
payload.get("name")
or payload.get("device")
or payload.get("vgname")
or payload.get("pool")
or f"{kind}-{secrets.token_hex(2)}"
)
ops = await load_node_ops(request, node)
items = _disk_items(ops, kind)
if any(str(item.get("name")) == name for item in items):
raise ApiError(400, f"{kind} '{name}' already exists")
entry = {
key: value for key, value in payload.items() if key not in {"node", "delete", "digest"}
}
entry["name"] = name
items.append(entry)
ops.setdefault("disks", default_node_ops()["disks"])[kind] = items
await save_node_ops(request, node, ops)
return entry
async def disks_delete(request: Request, inputs: dict[str, Any], kind: str) -> None:
payload = values(inputs)
node = str(payload["node"])
name = str(payload["name"])
await require_node(request, node)
ops = await load_node_ops(request, node)
items = _disk_items(ops, kind)
remaining = [item for item in items if str(item.get("name")) != name]
if len(remaining) == len(items):
raise ApiError(404, f"{kind} does not exist")
ops.setdefault("disks", {})[kind] = remaining
await save_node_ops(request, node, ops)
async def disks_zfs_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
name = str(payload["name"])
await require_node(request, node)
ops = await load_node_ops(request, node)
for item in _disk_items(ops, "zfs"):
if str(item.get("name")) == name:
return dict(item)
raise ApiError(404, "zfs pool does not exist")
async def certificates_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("acme", "custom", "info")
async def certificates_acme_index(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("certificate")
async def certificates_acme_mutate(request: Request, inputs: dict[str, Any]) -> str | None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
certs = _certificates(ops)
acme = dict(certs.get("acme") or {})
method = request.method.upper()
if method == "DELETE":
acme["certificate"] = None
acme["domains"] = []
else:
domains = payload.get("domains") or payload.get("domain") or acme.get("domains") or []
if isinstance(domains, str):
domains = [part.strip() for part in domains.split(",") if part.strip()]
acme["domains"] = list(domains)
acme["account"] = str(payload.get("account") or acme.get("account") or "default")
acme["certificate"] = {
"pem": str(payload.get("certificates") or payload.get("certificate") or "SIM-ACME"),
"issued": int(time.time()),
}
certs["acme"] = acme
ops["certificates"] = certs
await save_node_ops(request, node, ops)
if method == "DELETE":
return None
return await _node_task(request, node=node, task_type="acme", worker="acme")
async def certificates_custom(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
certs = _certificates(ops)
if request.method.upper() == "DELETE":
certs["custom"] = None
else:
certificates = str(payload.get("certificates") or payload.get("cert") or "")
if not certificates:
raise ApiError(400, "parameter verification failed - 'certificates' missing")
key = str(payload.get("key") or payload.get("private-key") or "")
certs["custom"] = {
"certificates": certificates,
"key": key,
"restart": int(payload.get("restart") or 0),
"filename": str(payload.get("filename") or "pveproxy-ssl.pem"),
}
info = list(certs.get("info") or [])
info = [item for item in info if item.get("filename") != certs["custom"]["filename"]]
info.append(
{
"filename": certs["custom"]["filename"],
"fingerprint": secrets.token_hex(20),
"issuer": "CN=Simulator",
"subject": "CN=pve.local",
"notbefore": int(time.time()) - 86_400,
"notafter": int(time.time()) + 365 * 86_400,
"san": ["DNS:pve.local"],
"public-key-type": "rsa",
"public-key-bits": 2048,
}
)
certs["info"] = info
ops["certificates"] = certs
await save_node_ops(request, node, ops)
async def certificates_info(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
certs = _certificates(ops)
info = list(certs.get("info") or [])
custom = _public_cert(
certs.get("custom") if isinstance(certs.get("custom"), dict) else None
)
if custom and not any(item.get("filename") == custom.get("filename") for item in info):
info.append(
{
"filename": custom.get("filename", "pveproxy-ssl.pem"),
"fingerprint": secrets.token_hex(20),
"issuer": "CN=Custom",
"subject": "CN=pve.local",
}
)
certs["info"] = info
ops["certificates"] = certs
await save_node_ops(request, node, ops)
return [dict(item) for item in info]
async def scan_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("cifs", "iscsi", "lvm", "lvmthin", "nfs", "pbs", "zfs")
async def scan_kind(
request: Request, inputs: dict[str, Any], kind: str
) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
scan = _scan_cache(ops)
ops["scan"] = scan
await save_node_ops(request, node, ops)
items = scan.get(kind, [])
return [dict(item) for item in items] if isinstance(items, list) else []
async def capabilities_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("qemu")
async def capabilities_qemu(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("cpu", "cpu-flags", "machines", "migration")
async def capabilities_cpu(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
return [
{"name": "host", "vendor": "QEMU", "custom": 0},
{"name": "x86-64-v2-AES", "vendor": "QEMU", "custom": 0},
{"name": "kvm64", "vendor": "QEMU", "custom": 0},
]
async def capabilities_cpu_flags(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
return [
{"name": "aes", "introduces": "Westmere"},
{"name": "avx", "introduces": "SandyBridge"},
{"name": "avx2", "introduces": "Haswell"},
]
async def capabilities_machines(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
await require_node(request, str(values(inputs)["node"]))
return [
{"id": "pc-i440fx-9.0", "type": "i440fx", "version": "9.0"},
{"id": "pc-q35-9.0", "type": "q35", "version": "9.0"},
]
async def capabilities_migration(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(request, str(values(inputs)["node"]))
return {"network": "", "type": "secure", "enabled": 1}
async def hardware_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await require_node(request, str(values(inputs)["node"]))
return subdirs("pci", "usb")
async def hardware_pci(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
hardware = _hardware(ops)
ops["hardware"] = hardware
await save_node_ops(request, node, ops)
return [dict(item) for item in hardware.get("pci", [])]
async def hardware_pci_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
pci_id = str(payload.get("pci-id-or-mapping") or payload.get("pciid") or "")
await require_node(request, node)
ops = await load_node_ops(request, node)
for item in _hardware(ops).get("pci", []):
if str(item.get("id")) == pci_id:
return dict(item)
raise ApiError(404, "pci device does not exist")
async def hardware_pci_mdev(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
node = str(payload["node"])
pci_id = str(payload.get("pci-id-or-mapping") or payload.get("pciid") or "")
await require_node(request, node)
ops = await load_node_ops(request, node)
hardware = _hardware(ops)
mdev = hardware.get("mdev", {})
items = mdev.get(pci_id, []) if isinstance(mdev, dict) else []
return [dict(item) for item in items] if isinstance(items, list) else []
async def hardware_usb(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
hardware = _hardware(ops)
ops["hardware"] = hardware
await save_node_ops(request, node, ops)
return [dict(item) for item in hardware.get("usb", [])]
async def subscription_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
public = dict(_subscription(ops))
public.pop("key", None)
return public
async def subscription_mutate(
request: Request, inputs: dict[str, Any]
) -> dict[str, Any] | None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
current = _subscription(ops)
method = request.method.upper()
if method == "DELETE":
ops["subscription"] = copy.deepcopy(DEFAULT_SUBSCRIPTION)
await save_node_ops(request, node, ops)
return None
if method == "POST":
current["checktime"] = int(time.time())
current["status"] = current.get("status") or "Active"
ops["subscription"] = current
await save_node_ops(request, node, ops)
return dict(current)
key = str(payload.get("key") or current.get("key") or "")
updated = {
**current,
**{k: v for k, v in payload.items() if k not in {"node", "delete", "digest"}},
"key": key,
"status": "Active" if key else current.get("status", "notfound"),
"message": "OK" if key else current.get("message", "There is no subscription key"),
}
ops["subscription"] = updated
await save_node_ops(request, node, ops)
public = dict(updated)
public.pop("key", None)
return public
async def aplinfo_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
items = ops.get("aplinfo")
if not isinstance(items, list):
items = [
{
"package": "alpine-3-standard",
"section": "system",
"type": "lxc",
"version": "3.20",
}
]
ops["aplinfo"] = items
await save_node_ops(request, node, ops)
return [dict(item) for item in items]
async def aplinfo_download(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
downloads = list(ops.get("aplinfo_downloads") or [])
downloads.append(
{
"template": str(payload.get("template") or payload.get("storage") or "unknown"),
"at": int(time.time()),
}
)
ops["aplinfo_downloads"] = downloads
await save_node_ops(request, node, ops)
return await _node_task(request, node=node, task_type="download", worker="download")
async def apt_repositories_mutate(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
apt = ops.setdefault("apt", copy.deepcopy(default_node_ops()["apt"]))
repositories = list(apt.get("repositories") or [])
method = request.method.upper()
if method == "POST":
entry = {
key: value
for key, value in payload.items()
if key not in {"node", "delete", "digest"}
}
entry.setdefault("path", f"/etc/apt/sources.list.d/sim-{secrets.token_hex(2)}.list")
entry.setdefault("enabled", 1)
repositories.append(entry)
else:
path = payload.get("path")
handle = payload.get("handle")
index = payload.get("index")
updated: list[dict[str, Any]] = []
for idx, item in enumerate(repositories):
match = False
if path is not None and item.get("path") == path:
match = True
if handle is not None and item.get("handle") == handle:
match = True
if index is not None and idx == int(index):
match = True
if match or (path is None and handle is None and index is None and idx == 0):
merged = {
**item,
**{
key: value
for key, value in payload.items()
if key not in {"node", "delete", "digest", "path", "handle", "index"}
},
}
updated.append(merged)
else:
updated.append(item)
repositories = updated
apt["repositories"] = repositories
ops["apt"] = apt
await save_node_ops(request, node, ops)
async def node_config_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
config = ops.get("config")
if not isinstance(config, dict):
config = copy.deepcopy(DEFAULT_CONFIG)
ops["config"] = config
await save_node_ops(request, node, ops)
return dict(config)
async def node_config_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
config = dict(ops.get("config") or DEFAULT_CONFIG)
config.update(
{
key: value
for key, value in payload.items()
if key not in {"node", "digest", "delete"}
}
)
ops["config"] = config
await save_node_ops(request, node, ops)
return config
async def dns_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
dns = ops.get("dns")
if not isinstance(dns, dict):
dns = copy.deepcopy(DEFAULT_DNS)
ops["dns"] = dns
await save_node_ops(request, node, ops)
return dict(dns)
async def dns_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
dns = dict(ops.get("dns") or DEFAULT_DNS)
dns.update(
{
key: value
for key, value in payload.items()
if key not in {"node", "digest", "delete"}
}
)
ops["dns"] = dns
await save_node_ops(request, node, ops)
return dns
async def time_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
current = dict(ops.get("time") or DEFAULT_TIME)
now = int(time.time())
current["time"] = now
current["localtime"] = now
current.setdefault("timezone", "UTC")
ops["time"] = current
await save_node_ops(request, node, ops)
return current
async def time_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
current = dict(ops.get("time") or DEFAULT_TIME)
if "timezone" in payload:
current["timezone"] = str(payload["timezone"])
now = int(time.time())
current["time"] = now
current["localtime"] = now
ops["time"] = current
await save_node_ops(request, node, ops)
return current
async def execute(request: Request, inputs: dict[str, Any]) -> list[str]:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
commands = payload.get("commands") or payload.get("command") or []
if isinstance(commands, str):
try:
parsed = json.loads(commands)
commands = parsed if isinstance(parsed, list) else [commands]
except json.JSONDecodeError:
commands = [commands]
ops = await load_node_ops(request, node)
log = list(ops.get("execute_log") or [])
output: list[str] = []
for command in commands:
entry = {"command": str(command), "at": int(time.time())}
log.append(entry)
output.append(f"OK: {command}")
ops["execute_log"] = log[-100:]
await save_node_ops(request, node, ops)
return output
async def hosts_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
hosts = ops.get("hosts")
if not isinstance(hosts, dict):
hosts = {
"data": f"127.0.0.1 localhost\n10.0.0.10 {node}\n",
"digest": secrets.token_hex(8),
}
ops["hosts"] = hosts
await save_node_ops(request, node, ops)
return {"data": str(hosts.get("data", "")), "digest": str(hosts.get("digest", ""))}
async def hosts_post(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
ops["hosts"] = {
"data": str(payload.get("data") or ""),
"digest": secrets.token_hex(8),
}
await save_node_ops(request, node, ops)
async def journal(request: Request, inputs: dict[str, Any]) -> list[str]:
node = str(values(inputs)["node"])
await require_node(request, node)
start = int(values(inputs).get("startcursor") or values(inputs).get("start") or 0)
limit = int(values(inputs).get("limit") or 50)
lines = [
f"{index}: {node} systemd[1]: Started simulated service {index}."
for index in range(start, start + limit)
]
return lines
async def syslog(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
limit = int(values(inputs).get("limit") or 50)
return [
{"n": index, "t": f"{node} kernel: simulated syslog line {index}"}
for index in range(limit)
]
async def netstat(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
return [
{"in": 1_000_000, "out": 900_000, "vnet": "vmbr0", "hwaddr": "bc:24:11:00:00:01"},
{"in": 500_000, "out": 450_000, "vnet": "vmbr1", "hwaddr": "bc:24:11:00:00:02"},
]
async def report(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
return f"==== Proxmox node report for {node} ====\nuptime: simulated\n"
async def rrd(_request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
await require_node(_request, str(values(inputs)["node"]))
return {"filename": "/var/lib/rrdcached/db/pve-node.rrd"}
async def rrddata(_request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
await require_node(_request, str(values(inputs)["node"]))
now = int(time.time())
return [
{"time": now - 120, "cpu": 0.05, "memused": 1_000_000_000},
{"time": now - 60, "cpu": 0.07, "memused": 1_100_000_000},
{"time": now, "cpu": 0.04, "memused": 1_050_000_000},
]
async def startall(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
await _set_guest_status(request, node, "running")
return await _node_task(request, node=node, task_type="startall", worker="startall")
async def stopall(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
await _set_guest_status(request, node, "stopped")
return await _node_task(request, node=node, task_type="stopall", worker="stopall")
async def suspendall(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
await _set_guest_status(request, node, "paused")
return await _node_task(request, node=node, task_type="suspendall", worker="suspendall")
async def migrateall(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
target = str(payload.get("target") or "")
await require_node(request, node)
if not target:
raise ApiError(400, "parameter verification failed - 'target' missing")
await _migrate_guests(request, node, target)
return await _node_task(request, node=node, task_type="migrateall", worker="migrateall")
async def status_post(request: Request, inputs: dict[str, Any]) -> str | None:
payload = values(inputs)
node = str(payload["node"])
await require_node(request, node)
command = str(payload.get("command") or "reboot")
ops = await load_node_ops(request, node)
ops["last_status_command"] = {"command": command, "at": int(time.time())}
await save_node_ops(request, node, ops)
return await _node_task(request, node=node, task_type=command, worker=command)
async def wakeonlan(request: Request, inputs: dict[str, Any]) -> str:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
ops["wakeonlan"] = {"at": int(time.time())}
await save_node_ops(request, node, ops)
return "OK"
async def _shell_proxy(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
payload = {
"port": 5900 if kind == "vnc" else 22 if kind == "term" else 3128,
"ticket": secrets.token_urlsafe(24),
"user": str(getattr(request.state, "principal", "root@pam")),
"upid": f"UPID:{node}:{secrets.token_hex(4)}:{kind}shell:0:root@pam:",
}
ops = await load_node_ops(request, node)
shells = ops.setdefault("shells", {})
shells[kind] = {key: value for key, value in payload.items() if key != "ticket"}
ops["shells"] = shells
await save_node_ops(request, node, ops)
return payload
async def spiceshell(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _shell_proxy(request, inputs, "spice")
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _shell_proxy(request, inputs, "term")
async def vncshell(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _shell_proxy(request, inputs, "vnc")
async def network_reload(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
if not isinstance(ops.get("network"), list):
ops["network"] = copy.deepcopy(default_node_ops()["network"])
ops["network_applied"] = False
await save_node_ops(request, node, ops)
async def query_oci_repo_tags(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
repo = str(values(inputs).get("repo") or "library/alpine")
ops = await load_node_ops(request, node)
cache = ops.setdefault("oci_tags", {})
if repo not in cache:
cache[repo] = [{"tag": "latest"}, {"tag": "3.20"}]
ops["oci_tags"] = cache
await save_node_ops(request, node, ops)
return [dict(item) for item in cache[repo]]
async def query_url_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
url = str(values(inputs).get("url") or "")
ops = await load_node_ops(request, node)
cache = ops.setdefault("url_metadata", {})
if url not in cache:
cache[url] = {
"filename": url.rsplit("/", 1)[-1] or "download.bin",
"mimetype": "application/octet-stream",
"size": 1024,
}
ops["url_metadata"] = cache
await save_node_ops(request, node, ops)
return dict(cache[url])
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
await require_node(request, node)
ops = await load_node_ops(request, node)
shell = (ops.get("shells") or {}).get("vnc") or {"port": 5900}
return {
"port": shell.get("port", 5900),
"ticket": secrets.token_urlsafe(24),
}
# Disks mutations (GET collections already registered in nodes.py)
registry.register(
"/nodes/{node}/disks/directory",
"POST",
lambda request, inputs: disks_create(request, inputs, "directory"),
)
registry.register(
"/nodes/{node}/disks/directory/{name}",
"DELETE",
lambda request, inputs: disks_delete(request, inputs, "directory"),
)
registry.register(
"/nodes/{node}/disks/lvm",
"POST",
lambda request, inputs: disks_create(request, inputs, "lvm"),
)
registry.register(
"/nodes/{node}/disks/lvm/{name}",
"DELETE",
lambda request, inputs: disks_delete(request, inputs, "lvm"),
)
registry.register(
"/nodes/{node}/disks/lvmthin",
"POST",
lambda request, inputs: disks_create(request, inputs, "lvmthin"),
)
registry.register(
"/nodes/{node}/disks/lvmthin/{name}",
"DELETE",
lambda request, inputs: disks_delete(request, inputs, "lvmthin"),
)
registry.register(
"/nodes/{node}/disks/zfs",
"POST",
lambda request, inputs: disks_create(request, inputs, "zfs"),
)
registry.register("/nodes/{node}/disks/zfs/{name}", "GET", disks_zfs_get)
registry.register(
"/nodes/{node}/disks/zfs/{name}",
"DELETE",
lambda request, inputs: disks_delete(request, inputs, "zfs"),
)
registry.register("/nodes/{node}/certificates", "GET", certificates_index)
registry.register("/nodes/{node}/certificates/acme", "GET", certificates_acme_index)
registry.register(
"/nodes/{node}/certificates/acme/certificate", "POST", certificates_acme_mutate
)
registry.register(
"/nodes/{node}/certificates/acme/certificate", "PUT", certificates_acme_mutate
)
registry.register(
"/nodes/{node}/certificates/acme/certificate", "DELETE", certificates_acme_mutate
)
registry.register("/nodes/{node}/certificates/custom", "POST", certificates_custom)
registry.register("/nodes/{node}/certificates/custom", "DELETE", certificates_custom)
registry.register("/nodes/{node}/certificates/info", "GET", certificates_info)
registry.register("/nodes/{node}/scan", "GET", scan_index)
registry.register("/nodes/{node}/scan/cifs", "GET", lambda r, i: scan_kind(r, i, "cifs"))
registry.register("/nodes/{node}/scan/iscsi", "GET", lambda r, i: scan_kind(r, i, "iscsi"))
registry.register("/nodes/{node}/scan/lvm", "GET", lambda r, i: scan_kind(r, i, "lvm"))
registry.register("/nodes/{node}/scan/lvmthin", "GET", lambda r, i: scan_kind(r, i, "lvmthin"))
registry.register("/nodes/{node}/scan/nfs", "GET", lambda r, i: scan_kind(r, i, "nfs"))
registry.register("/nodes/{node}/scan/pbs", "GET", lambda r, i: scan_kind(r, i, "pbs"))
registry.register("/nodes/{node}/scan/zfs", "GET", lambda r, i: scan_kind(r, i, "zfs"))
registry.register("/nodes/{node}/capabilities", "GET", capabilities_index)
registry.register("/nodes/{node}/capabilities/qemu", "GET", capabilities_qemu)
registry.register("/nodes/{node}/capabilities/qemu/cpu", "GET", capabilities_cpu)
registry.register("/nodes/{node}/capabilities/qemu/cpu-flags", "GET", capabilities_cpu_flags)
registry.register("/nodes/{node}/capabilities/qemu/machines", "GET", capabilities_machines)
registry.register("/nodes/{node}/capabilities/qemu/migration", "GET", capabilities_migration)
registry.register("/nodes/{node}/hardware", "GET", hardware_index)
registry.register("/nodes/{node}/hardware/pci", "GET", hardware_pci)
registry.register("/nodes/{node}/hardware/pci/{pci-id-or-mapping}", "GET", hardware_pci_get)
registry.register(
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}/mdev", "GET", hardware_pci_mdev
)
registry.register("/nodes/{node}/hardware/usb", "GET", hardware_usb)
registry.register("/nodes/{node}/subscription", "GET", subscription_get)
registry.register("/nodes/{node}/subscription", "PUT", subscription_mutate)
registry.register("/nodes/{node}/subscription", "POST", subscription_mutate)
registry.register("/nodes/{node}/subscription", "DELETE", subscription_mutate)
registry.register("/nodes/{node}/aplinfo", "GET", aplinfo_get)
registry.register("/nodes/{node}/aplinfo", "POST", aplinfo_download)
registry.register("/nodes/{node}/apt/repositories", "POST", apt_repositories_mutate)
registry.register("/nodes/{node}/apt/repositories", "PUT", apt_repositories_mutate)
registry.register("/nodes/{node}/config", "GET", node_config_get)
registry.register("/nodes/{node}/config", "PUT", node_config_put)
registry.register("/nodes/{node}/dns", "GET", dns_get)
registry.register("/nodes/{node}/dns", "PUT", dns_put)
registry.register("/nodes/{node}/time", "GET", time_get)
registry.register("/nodes/{node}/time", "PUT", time_put)
registry.register("/nodes/{node}/execute", "POST", execute)
registry.register("/nodes/{node}/hosts", "GET", hosts_get)
registry.register("/nodes/{node}/hosts", "POST", hosts_post)
registry.register("/nodes/{node}/journal", "GET", journal)
registry.register("/nodes/{node}/syslog", "GET", syslog)
registry.register("/nodes/{node}/netstat", "GET", netstat)
registry.register("/nodes/{node}/report", "GET", report)
registry.register("/nodes/{node}/rrd", "GET", rrd)
registry.register("/nodes/{node}/rrddata", "GET", rrddata)
registry.register("/nodes/{node}/migrateall", "POST", migrateall)
registry.register("/nodes/{node}/startall", "POST", startall)
registry.register("/nodes/{node}/stopall", "POST", stopall)
registry.register("/nodes/{node}/suspendall", "POST", suspendall)
registry.register("/nodes/{node}/status", "POST", status_post)
registry.register("/nodes/{node}/wakeonlan", "POST", wakeonlan)
registry.register("/nodes/{node}/spiceshell", "POST", spiceshell)
registry.register("/nodes/{node}/termproxy", "POST", termproxy)
registry.register("/nodes/{node}/vncshell", "POST", vncshell)
registry.register("/nodes/{node}/network", "DELETE", network_reload)
registry.register("/nodes/{node}/query-oci-repo-tags", "GET", query_oci_repo_tags)
registry.register("/nodes/{node}/query-url-metadata", "GET", query_url_metadata)
registry.register("/nodes/{node}/vncwebsocket", "GET", vncwebsocket)
+276
View File
@@ -0,0 +1,276 @@
"""Cluster notifications endpoints and matchers persisted in metadata."""
from __future__ import annotations
import time
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
_SECRET_KEYS = frozenset({"token", "password", "secret"})
DEFAULT_MATCHER_FIELDS = [
{"name": "type", "type": "string"},
{"name": "hostname", "type": "string"},
{"name": "job-id", "type": "string"},
{"name": "severity", "type": "string"},
]
DEFAULT_MATCHER_FIELD_VALUES = [
{"field": "type", "value": "fencing"},
{"field": "type", "value": "package-updates"},
{"field": "type", "value": "replication"},
{"field": "type", "value": "system-mail"},
]
def _public(endpoint: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in endpoint.items() if key not in _SECRET_KEYS}
def _notifications(metadata: dict[str, Any]) -> dict[str, Any]:
current = metadata.setdefault(
"notifications",
{
"endpoints": {
"gotify": {},
"sendmail": {},
"smtp": {},
"webhook": {},
},
"matchers": {},
"tests": [],
},
)
if not isinstance(current, dict):
current = {
"endpoints": {"gotify": {}, "sendmail": {}, "smtp": {}, "webhook": {}},
"matchers": {},
"tests": [],
}
metadata["notifications"] = current
current.setdefault(
"endpoints",
{"gotify": {}, "sendmail": {}, "smtp": {}, "webhook": {}},
)
current.setdefault("matchers", {})
current.setdefault("tests", [])
return current
def register_notifications_handlers(registry: HandlerRegistry) -> None:
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs(
"endpoints",
"matcher-field-values",
"matcher-fields",
"matchers",
"targets",
)
async def endpoints_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
return subdirs("gotify", "sendmail", "smtp", "webhook")
def register_kind(kind: str, create_keys: tuple[str, ...]) -> None:
base = f"/cluster/notifications/endpoints/{kind}"
async def list_endpoints(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
return [_public({"name": name, **item}) for name, item in sorted(store.items())]
async def create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
if name in store:
raise ApiError(400, f"{kind} endpoint '{name}' already exists")
entry = {key: payload[key] for key in create_keys if key in payload}
entry["name"] = name
entry.setdefault("disable", 0)
store[name] = entry
await save_cluster_metadata(request, metadata)
async def get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
if name not in store:
raise ApiError(404, f"{kind} endpoint does not exist")
return _public({"name": name, **store[name]})
async def update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
if name not in store:
raise ApiError(404, f"{kind} endpoint does not exist")
current = dict(store[name])
delete_keys = [
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
]
for key in delete_keys:
current.pop(key, None)
for key, value in payload.items():
if key in {"name", "delete", "digest"}:
continue
current[key] = value
current["name"] = name
store[name] = current
await save_cluster_metadata(request, metadata)
async def delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
if name not in store:
raise ApiError(404, f"{kind} endpoint does not exist")
del store[name]
await save_cluster_metadata(request, metadata)
registry.register(base, "GET", list_endpoints)
registry.register(base, "POST", create)
registry.register(f"{base}/{{name}}", "GET", get)
registry.register(f"{base}/{{name}}", "PUT", update)
registry.register(f"{base}/{{name}}", "DELETE", delete)
async def matchers_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
return [{"name": name, **item} for name, item in sorted(store.items())]
async def matchers_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
if name in store:
raise ApiError(400, f"matcher '{name}' already exists")
store[name] = {
key: value for key, value in payload.items() if key not in {"delete", "digest"}
}
await save_cluster_metadata(request, metadata)
async def matchers_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
if name not in store:
raise ApiError(404, "matcher does not exist")
return {"name": name, **store[name]}
async def matchers_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
name = str(payload["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
if name not in store:
raise ApiError(404, "matcher does not exist")
current = dict(store[name])
for key in [
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
]:
current.pop(key, None)
for key, value in payload.items():
if key in {"name", "delete", "digest"}:
continue
current[key] = value
current["name"] = name
store[name] = current
await save_cluster_metadata(request, metadata)
async def matchers_delete(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
store = _notifications(metadata)["matchers"]
if name not in store:
raise ApiError(404, "matcher does not exist")
del store[name]
await save_cluster_metadata(request, metadata)
async def matcher_fields(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
return list(DEFAULT_MATCHER_FIELDS)
async def matcher_field_values(
_request: Request, _inputs: dict[str, Any]
) -> list[dict[str, Any]]:
return list(DEFAULT_MATCHER_FIELD_VALUES)
async def targets(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
metadata = await cluster_metadata(request)
notifications = _notifications(metadata)
result: list[dict[str, Any]] = []
for kind, store in (notifications.get("endpoints") or {}).items():
if not isinstance(store, dict):
continue
for name, item in store.items():
result.append(
{
"name": name,
"type": kind,
"comment": item.get("comment", ""),
"disable": int(bool(item.get("disable"))),
}
)
return result
async def target_test(request: Request, inputs: dict[str, Any]) -> None:
name = str(values(inputs)["name"])
metadata = await cluster_metadata(request)
notifications = _notifications(metadata)
found = False
for store in (notifications.get("endpoints") or {}).values():
if isinstance(store, dict) and name in store:
found = True
break
if not found:
raise ApiError(404, "notification target does not exist")
tests = notifications.setdefault("tests", [])
if not isinstance(tests, list):
tests = notifications["tests"] = []
tests.append({"name": name, "tested_at": int(time.time()), "ok": True})
await save_cluster_metadata(request, metadata)
registry.register("/cluster/notifications", "GET", index)
registry.register("/cluster/notifications/endpoints", "GET", endpoints_index)
register_kind("gotify", ("comment", "disable", "name", "server", "token"))
register_kind(
"sendmail",
("author", "comment", "disable", "from-address", "mailto", "mailto-user", "name"),
)
register_kind(
"smtp",
(
"author",
"comment",
"disable",
"from-address",
"mailto",
"mailto-user",
"mode",
"name",
"password",
"port",
"server",
"username",
),
)
register_kind(
"webhook",
("body", "comment", "disable", "header", "method", "name", "secret", "url"),
)
registry.register("/cluster/notifications/matchers", "GET", matchers_list)
registry.register("/cluster/notifications/matchers", "POST", matchers_create)
registry.register("/cluster/notifications/matchers/{name}", "GET", matchers_get)
registry.register("/cluster/notifications/matchers/{name}", "PUT", matchers_update)
registry.register("/cluster/notifications/matchers/{name}", "DELETE", matchers_delete)
registry.register("/cluster/notifications/matcher-fields", "GET", matcher_fields)
registry.register("/cluster/notifications/matcher-field-values", "GET", matcher_field_values)
registry.register("/cluster/notifications/targets", "GET", targets)
registry.register("/cluster/notifications/targets/{name}/test", "POST", target_test)
+134
View File
@@ -0,0 +1,134 @@
"""Resource pool semantic handlers."""
from __future__ import annotations
import json
import uuid
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, state, values
from app.simulation.seed import CLUSTER_ID, stable_id
async def _pool_members(request: Request, pool_id: uuid.UUID) -> list[str]:
rows = await database(request).pool.fetch(
"""SELECT r.external_id FROM pool_members pm
JOIN resources r ON r.id = pm.resource_id
WHERE pm.pool_id=$1 ORDER BY r.external_id::integer""",
pool_id,
)
return [str(row["external_id"]) for row in rows]
def register_pool_handlers(registry: HandlerRegistry) -> None:
async def pool_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
filter_poolid = payload.get("poolid")
rows = await database(request).pool.fetch(
"""SELECT id, pool_id, comment, metadata FROM pools
WHERE ($1::text IS NULL OR pool_id=$1)
ORDER BY pool_id""",
str(filter_poolid) if filter_poolid is not None else None,
)
result: list[dict[str, Any]] = []
for row in rows:
metadata = state(row["metadata"])
members = await _pool_members(request, row["id"])
if not members and isinstance(metadata.get("members"), list):
members = [str(item) for item in metadata["members"]]
item: dict[str, Any] = {
"poolid": str(row["pool_id"]),
"members": members,
}
if row["comment"] is not None:
item["comment"] = str(row["comment"])
elif metadata.get("comment"):
item["comment"] = str(metadata["comment"])
result.append(item)
return result
async def pool_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
items = await pool_list(request, inputs)
if not items:
raise ApiError(404, "pool does not exist")
return items[0]
async def pool_create(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
poolid = str(payload["poolid"])
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM pools WHERE pool_id=$1)",
poolid,
)
if exists:
raise ApiError(409, "pool already exists")
await database(request).pool.execute(
"""INSERT INTO pools(id, cluster_id, pool_id, comment, metadata)
VALUES($1, $2, $3, $4, $5::jsonb)""",
stable_id(f"pool:{poolid}"),
CLUSTER_ID,
poolid,
payload.get("comment"),
json.dumps({"members": []}, sort_keys=True),
)
async def pool_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
poolid = str(payload["poolid"])
pool_row = await database(request).pool.fetchrow(
"SELECT id FROM pools WHERE pool_id=$1",
poolid,
)
if pool_row is None:
raise ApiError(404, "pool does not exist")
if payload.get("comment") is not None:
await database(request).pool.execute(
"UPDATE pools SET comment=$2 WHERE pool_id=$1",
poolid,
payload.get("comment"),
)
if "vms" in payload:
vmids = [item.strip() for item in str(payload["vms"]).split(",") if item.strip()]
for vmid in vmids:
resource = await database(request).pool.fetchrow(
"""SELECT id FROM resources
WHERE kind IN ('qemu', 'lxc') AND external_id=$1""",
vmid,
)
if resource is None:
continue
await database(request).pool.execute(
"""INSERT INTO pool_members(pool_id, resource_id)
VALUES($1, $2) ON CONFLICT DO NOTHING""",
pool_row["id"],
resource["id"],
)
if "delete" in payload:
vmids = [item.strip() for item in str(payload["delete"]).split(",") if item.strip()]
await database(request).pool.execute(
"""DELETE FROM pool_members pm USING resources r
WHERE pm.pool_id=$1 AND pm.resource_id=r.id AND r.external_id = ANY($2::text[])""",
pool_row["id"],
vmids,
)
async def pool_delete(request: Request, inputs: dict[str, Any]) -> None:
poolid = str(values(inputs)["poolid"])
status = await database(request).pool.execute(
"DELETE FROM pools WHERE pool_id=$1",
poolid,
)
if status != "DELETE 1":
raise ApiError(404, "pool does not exist")
registry.register("/pools", "GET", pool_list)
registry.register("/pools", "POST", pool_create)
registry.register("/pools", "PUT", pool_update)
registry.register("/pools", "DELETE", pool_delete)
registry.register("/pools/{poolid}", "GET", pool_get)
registry.register("/pools/{poolid}", "PUT", pool_update)
registry.register("/pools/{poolid}", "DELETE", pool_delete)
+865
View File
@@ -0,0 +1,865 @@
"""Basic persistent QEMU and task semantic handlers."""
from __future__ import annotations
import json
import re
from collections.abc import Mapping
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.handlers.common import require_node, subdirs
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
from app.tasks.repository import TaskRepository
from app.tasks.upid import Upid
def _database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def _values(inputs: dict[str, Any]) -> dict[str, Any]:
return cast(dict[str, Any], inputs["values"])
def _state(value: object) -> dict[str, Any]:
if isinstance(value, str):
return cast(dict[str, Any], json.loads(value))
return dict(cast(Mapping[str, Any], value))
def register_qemu_handlers(registry: HandlerRegistry) -> None:
async def qemu_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(_values(inputs)["node"])
rows = await _database(request).pool.fetch(
"""SELECT r.external_id::integer AS vmid, r.state
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='qemu' ORDER BY r.external_id::integer""",
node,
)
return [{"vmid": int(row["vmid"]), **_state(row["state"])} for row in rows]
async def qemu_status_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs)
await _qemu_resource(request, str(payload["node"]), str(payload["vmid"]))
return subdirs(
"current",
"reboot",
"reset",
"resume",
"shutdown",
"start",
"stop",
"suspend",
)
async def qemu_status_current(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
node, vmid = str(payload["node"]), int(payload["vmid"])
resource = await _qemu_resource(request, node, str(vmid))
vm_state = _state(resource["state"])
config = _state(resource["config"])
status = str(vm_state.get("status", "stopped"))
running = status in {"running", "paused"}
memory_mb = int(config.get("memory", config.get("mem", 2048)))
maxmem = memory_mb * 2**20
mem_used = int(vm_state.get("mem", maxmem // 2 if running else 0))
uptime = int(
vm_state.get(
"uptime",
int(
await _database(request).pool.fetchval(
"SELECT extract(epoch from now())::bigint"
)
)
% 86_400
if running
else 0,
)
)
return {
"vmid": vmid,
"name": str(config.get("name", f"vm-{vmid}")),
"status": status,
"qmpstatus": status if running else "stopped",
"lock": str(vm_state.get("lock", "")),
"pid": int(vm_state.get("pid", 12_345 if running else 0)),
"cpus": int(config.get("cores", config.get("cpus", 1))),
"maxmem": maxmem,
"mem": mem_used,
"balloon": int(vm_state.get("balloon", 0)),
"ballooninfo": {
"actual": mem_used,
"max_mem": maxmem,
"mem_swapped_in": 0,
"mem_swapped_out": 0,
},
"uptime": uptime,
"template": int(bool(vm_state.get("template", False))),
"ha": {"managed": int(vm_state.get("ha_managed", 0))},
"agent": 1 if running and str(config.get("agent", "0")).startswith("1") else 0,
}
async def qemu_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
row = await _database(request).pool.fetchrow(
"""SELECT r.state, v.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN virtual_machines v ON v.resource_id=r.id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine does not exist")
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
async def mutate(operation: str, request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine does not exist")
current = str(_state(row["state"]).get("status", "stopped"))
try:
plan_transition(VmState(current), operation)
except (InvalidTransitionError, ValueError) as error:
raise ApiError(409, f"cannot {operation} VM while it is {current}") from error
upid = str(Upid.allocate(node, f"qm{operation}", vmid, str(request.state.principal)))
try:
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=f"qemu-{operation}",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
resource_key=f"qemu:{vmid}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def create(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), int(values["vmid"])
database = _database(request)
if not await database.pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", node
):
raise ApiError(404, "node does not exist")
if await database.pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
str(vmid),
):
raise ApiError(409, "VMID already exists")
config = {
key: value
for key, value in values.items()
if key not in {"node", "vmid", "force", "archive", "start"}
}
return await _create_task(
request,
node=node,
vmid=str(vmid),
task_type="qemu-create",
payload={"node": node, "vmid": vmid, "config": config},
)
async def update(request: Request, inputs: dict[str, Any], *, asynchronous: bool) -> str | None:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.version, r.state, v.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN virtual_machines v ON v.resource_id=r.id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine does not exist")
control = {"node", "vmid", "digest", "delete", "revert", "skiplock", "background_delay"}
provided = frozenset(str(item) for item in inputs.get("provided", values))
changes = {
key: value for key, value in values.items() if key in provided and key not in control
}
delete = str(values.get("delete", "")) if "delete" in provided else ""
if asynchronous:
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-update",
payload={
"node": node,
"vmid": vmid,
"resource_id": str(row["id"]),
"changes": changes,
"delete": delete,
},
)
state = _state(row["state"])
config = _state(row["config"])
state.update(changes)
config.update(changes)
for key in delete.split(","):
if key:
state.pop(key, None)
config.pop(key, None)
status = await database.pool.execute(
"""UPDATE resources SET state=$3::jsonb, version=version+1,
updated_at=now() WHERE id=$1 AND version=$2""",
row["id"],
row["version"],
json.dumps(state, sort_keys=True),
)
if status != "UPDATE 1":
raise ApiError(409, "configuration changed concurrently")
await database.pool.execute(
"""UPDATE virtual_machines SET config=$2::jsonb
WHERE resource_id=$1""",
row["id"],
json.dumps(config, sort_keys=True),
)
return None
async def update_async(request: Request, inputs: dict[str, Any]) -> str:
result = await update(request, inputs, asynchronous=True)
if not isinstance(result, str):
raise RuntimeError("async QEMU update did not create a task")
return result
async def update_sync(request: Request, inputs: dict[str, Any]) -> None:
await update(request, inputs, asynchronous=False)
async def delete(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
database = _database(request)
row = await database.pool.fetchrow(
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine does not exist")
if str(_state(row["state"]).get("status")) != "stopped":
raise ApiError(409, "cannot delete a running virtual machine")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-delete",
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
)
async def start(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("start", request, inputs)
async def stop(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("stop", request, inputs)
async def shutdown(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("shutdown", request, inputs)
async def reboot(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("reboot", request, inputs)
async def reset(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("reset", request, inputs)
async def suspend(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("suspend", request, inputs)
async def resume(request: Request, inputs: dict[str, Any]) -> str:
return await mutate("resume", request, inputs)
async def snapshot_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
rows = await _database(request).pool.fetch(
"""SELECT name, parent_name, description, created_at FROM snapshots
WHERE resource_id=$1 ORDER BY created_at, name""",
resource["id"],
)
return [
{
"name": row["name"],
"parent": row["parent_name"],
"description": row["description"] or "",
"snaptime": int(row["created_at"].timestamp()),
}
for row in rows
]
async def snapshot_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
row = await _snapshot(request, values)
state = _state(row["state"])
return {
"name": row["name"],
"parent": row["parent_name"],
"description": row["description"] or "",
"snaptime": int(row["created_at"].timestamp()),
**state,
}
async def snapshot_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
row = await _snapshot(request, _values(inputs))
return {"description": row["description"] or "", **_state(row["state"])}
async def snapshot_update(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
row = await _snapshot(request, values)
await _database(request).pool.execute(
"UPDATE snapshots SET description=$2 WHERE id=$1",
row["id"],
str(values.get("description", "")),
)
async def snapshot_task(operation: str, request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, snapname = (
str(values["node"]),
str(values["vmid"]),
str(values["snapname"]),
)
resource = await _qemu_resource(request, node, vmid)
if operation == "snapshot-create":
exists = await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM snapshots WHERE resource_id=$1 AND name=$2)",
resource["id"],
snapname,
)
if exists:
raise ApiError(409, "snapshot already exists")
else:
await _snapshot(request, values)
return await _create_task(
request,
node=node,
vmid=vmid,
task_type=f"qemu-{operation}",
payload={
"node": node,
"vmid": vmid,
"resource_id": str(resource["id"]),
"snapname": snapname,
"description": str(values.get("description", "")),
"vmstate": bool(values.get("vmstate", False)),
"start": bool(values.get("start", False)),
},
)
async def snapshot_create(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-create", request, inputs)
async def snapshot_delete(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-delete", request, inputs)
async def snapshot_rollback(request: Request, inputs: dict[str, Any]) -> str:
return await snapshot_task("snapshot-rollback", request, inputs)
async def clone(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, newid = str(values["node"]), str(values["vmid"]), str(values["newid"])
source = await _qemu_resource(request, node, vmid)
if await _database(request).pool.fetchval(
"""SELECT EXISTS(SELECT 1 FROM resources
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
newid,
):
raise ApiError(409, "VMID already exists")
target = str(values.get("target") or node)
if not await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
):
raise ApiError(404, "target node does not exist")
return await _create_task(
request,
node=target,
vmid=newid,
task_type="qemu-clone",
payload={
"source_resource_id": str(source["id"]),
"source_vmid": vmid,
"node": target,
"vmid": int(newid),
"name": values.get("name"),
"description": values.get("description"),
"full": bool(values.get("full", False)),
},
)
async def migrate_preconditions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
target = str(target)
exists = await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
)
if not exists:
raise ApiError(404, "target node does not exist")
return {"local_disks": [], "local_resources": [], "running": False}
async def migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid = str(values["node"]), str(values["vmid"])
target = values.get("target")
if target in {None, ""}:
raise ApiError(400, "parameter 'target' is required")
target = str(target)
resource = await _qemu_resource(request, node, vmid)
if target == node:
raise ApiError(400, "target node is the same as source node")
await migrate_preconditions(request, inputs)
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-migrate",
payload={
"resource_id": str(resource["id"]),
"node": node,
"target": target,
"vmid": vmid,
"online": bool(values.get("online", False)),
},
)
async def remote_migrate(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
target_endpoint = str(values.get("target-endpoint") or values.get("target_endpoint") or "")
target = str(values.get("target") or "")
if not target_endpoint:
raise ApiError(400, "parameter target-endpoint is required")
if not target:
raise ApiError(400, "parameter target is required")
node, vmid = str(values["node"]), str(values["vmid"])
resource = await _qemu_resource(request, node, vmid)
if target == node:
raise ApiError(400, "target node is the same as source node")
if not await _database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
):
raise ApiError(404, "target node does not exist")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-remote-migrate",
payload={
"resource_id": str(resource["id"]),
"node": node,
"target": target,
"vmid": vmid,
"target-endpoint": target_endpoint,
"online": bool(values.get("online", False)),
},
)
async def resize(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
resource = await _qemu_resource(request, node, vmid)
config = _state(resource["config"])
if disk not in config:
raise ApiError(400, f"disk {disk} does not exist")
current = _disk_size_bytes(str(config[disk]))
size = _resize_bytes(str(values["size"]), current)
config[disk] = _replace_disk_size(str(config[disk]), size)
status = await _database(request).pool.execute(
"""UPDATE virtual_machines SET config=$2::jsonb
WHERE resource_id=$1""",
resource["id"],
json.dumps(config, sort_keys=True),
)
if status != "UPDATE 1":
raise ApiError(409, "configuration changed concurrently")
await _database(request).pool.execute(
"""UPDATE resources SET state=state || $2::jsonb,version=version+1,
updated_at=now() WHERE id=$1""",
resource["id"],
json.dumps({disk: config[disk]}, sort_keys=True),
)
await _database(request).pool.execute(
"""INSERT INTO vm_disks(id,resource_id,device,storage_id,size_bytes)
VALUES(gen_random_uuid(),$1,$2,$3,$4)
ON CONFLICT(resource_id,device) DO UPDATE SET size_bytes=EXCLUDED.size_bytes""",
resource["id"],
disk,
str(config[disk]).split(":", 1)[0],
size,
)
async def move_disk(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
resource = await _qemu_resource(request, node, vmid)
if disk not in _state(resource["config"]):
raise ApiError(400, f"disk {disk} does not exist")
return await _create_task(
request,
node=node,
vmid=vmid,
task_type="qemu-move-disk",
payload={
"resource_id": str(resource["id"]),
"disk": disk,
"storage": str(values.get("storage") or "local-lvm"),
"target_vmid": int(values.get("target-vmid") or vmid),
"target_disk": str(values.get("target-disk") or disk),
"delete": bool(values.get("delete", True)),
},
)
async def pending(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
config = _state(resource["config"])
changes = cast(Mapping[str, Any], state.get("pending", {}))
return [
{"key": key, "value": str(config.get(key, "")), "pending": str(value)}
for key, value in sorted(changes.items())
]
async def agent_result(
command: str, request: Request, inputs: dict[str, Any]
) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
config = _state(resource["config"])
vmid = str(_values(inputs)["vmid"])
results: dict[str, Any] = {
"info": {
"version": "9.2.0-simulator",
"supported_commands": [
{"name": name, "enabled": True, "success-response": True}
for name in ("guest-ping", "guest-info", "guest-get-osinfo")
],
},
"get-osinfo": {
"name": str(config.get("ostype", "linux")),
"pretty-name": "Proxmox Simulator Guest",
"version": "1.0",
"machine": "x86_64",
},
"get-host-name": {"host-name": str(config.get("name", f"vm-{vmid}"))},
"network-get-interfaces": [
{
"name": "eth0",
"hardware-address": "02:00:00:00:00:01",
"ip-addresses": [
{"ip-address": "192.0.2.10", "ip-address-type": "ipv4", "prefix": 24}
],
}
],
"ping": {},
}
if command == "get-time":
seconds = int(
await _database(request).pool.fetchval("SELECT extract(epoch from now())::bigint")
)
return {"result": {"seconds": seconds, "nanoseconds": 0}}
return {"result": results[command]}
async def agent_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("info", request, inputs)
async def agent_osinfo(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("get-osinfo", request, inputs)
async def agent_hostname(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("get-host-name", request, inputs)
async def agent_network(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("network-get-interfaces", request, inputs)
async def agent_time(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("get-time", request, inputs)
async def agent_ping(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_result("ping", request, inputs)
async def task_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
tasks = await TaskRepository(_database(request).pool).list_for_node(
str(_values(inputs)["node"])
)
return [
{"upid": task.upid, "status": task.status, "type": task.task_type} for task in tasks
]
async def task_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
task = await TaskRepository(_database(request).pool).get_by_upid(
str(_values(inputs)["upid"])
)
if task is None:
raise ApiError(404, "task does not exist")
result: dict[str, Any] = {
"upid": task.upid,
"status": "stopped" if task.status in {"success", "error", "cancelled"} else "running",
"progress": task.progress,
}
if task.status in {"success", "error", "cancelled"}:
result["exitstatus"] = "OK" if task.status == "success" else task.status.upper()
return result
async def task_log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
repository = TaskRepository(_database(request).pool)
task = await repository.get_by_upid(str(_values(inputs)["upid"]))
if task is None:
raise ApiError(404, "task does not exist")
return [
{"n": index + 1, "t": message}
for index, message in enumerate(await repository.logs(task.id))
]
async def qemu_feature(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
await _qemu_resource(request, str(payload["node"]), str(payload["vmid"]))
return {
"hasFeature": {
"snapshot": 1,
"clone": 1,
"copy": 1,
"template": 1,
"move_disk": 1,
"agent": 1,
}
}
async def qemu_template(request: Request, inputs: dict[str, Any]) -> None:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
resource = await _qemu_resource(request, node, vmid)
state = _state(resource["state"])
if state.get("status") != "stopped":
raise ApiError(409, "virtual machine must be stopped to convert to template")
await _database(request).pool.execute(
"UPDATE virtual_machines SET template=true WHERE resource_id=$1",
resource["id"],
)
state["template"] = True
await _database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb WHERE id=$1",
resource["id"],
json.dumps(state, sort_keys=True),
)
async def qemu_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs)
node, vmid = str(payload["node"]), str(payload["vmid"])
await require_node(request, node)
await _qemu_resource(request, node, vmid)
return subdirs(
"agent",
"clone",
"config",
"feature",
"firewall",
"migrate",
"move_disk",
"pending",
"resize",
"snapshot",
"status",
"template",
)
async def task_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
payload = _values(inputs)
node, upid = str(payload["node"]), str(payload["upid"])
await require_node(request, node)
task = await TaskRepository(_database(request).pool).get_by_upid(upid)
if task is None:
raise ApiError(404, "task does not exist")
return subdirs("log", "status")
async def task_delete(request: Request, inputs: dict[str, Any]) -> None:
payload = _values(inputs)
upid = str(payload["upid"])
repository = TaskRepository(_database(request).pool)
task = await repository.get_by_upid(upid)
if task is None:
raise ApiError(404, "task does not exist")
if task.status in {"success", "error", "cancelled"}:
return
await repository.request_cancel(task.id)
registry.register("/nodes/{node}/qemu", "GET", qemu_list)
registry.register("/nodes/{node}/qemu", "POST", create)
registry.register("/nodes/{node}/qemu/{vmid}", "GET", qemu_index)
registry.register("/nodes/{node}/qemu/{vmid}", "DELETE", delete)
registry.register("/nodes/{node}/qemu/{vmid}/config", "GET", qemu_config)
registry.register("/nodes/{node}/qemu/{vmid}/config", "POST", update_async)
registry.register("/nodes/{node}/qemu/{vmid}/config", "PUT", update_sync)
registry.register("/nodes/{node}/qemu/{vmid}/status", "GET", qemu_status_index)
registry.register("/nodes/{node}/qemu/{vmid}/status/current", "GET", qemu_status_current)
registry.register("/nodes/{node}/qemu/{vmid}/status/start", "POST", start)
registry.register("/nodes/{node}/qemu/{vmid}/status/stop", "POST", stop)
registry.register("/nodes/{node}/qemu/{vmid}/status/shutdown", "POST", shutdown)
registry.register("/nodes/{node}/qemu/{vmid}/status/reboot", "POST", reboot)
registry.register("/nodes/{node}/qemu/{vmid}/status/reset", "POST", reset)
registry.register("/nodes/{node}/qemu/{vmid}/status/suspend", "POST", suspend)
registry.register("/nodes/{node}/qemu/{vmid}/status/resume", "POST", resume)
registry.register("/nodes/{node}/qemu/{vmid}/snapshot", "GET", snapshot_list)
registry.register("/nodes/{node}/qemu/{vmid}/snapshot", "POST", snapshot_create)
registry.register("/nodes/{node}/qemu/{vmid}/snapshot/{snapname}", "GET", snapshot_get)
registry.register("/nodes/{node}/qemu/{vmid}/snapshot/{snapname}", "DELETE", snapshot_delete)
registry.register(
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/config", "GET", snapshot_config
)
registry.register(
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/config", "PUT", snapshot_update
)
registry.register(
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/rollback", "POST", snapshot_rollback
)
registry.register("/nodes/{node}/qemu/{vmid}/clone", "POST", clone)
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "GET", migrate_preconditions)
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "POST", migrate)
registry.register("/nodes/{node}/qemu/{vmid}/remote_migrate", "POST", remote_migrate)
registry.register("/nodes/{node}/qemu/{vmid}/resize", "PUT", resize)
registry.register("/nodes/{node}/qemu/{vmid}/move_disk", "POST", move_disk)
registry.register("/nodes/{node}/qemu/{vmid}/pending", "GET", pending)
registry.register("/nodes/{node}/qemu/{vmid}/agent/info", "GET", agent_info)
registry.register("/nodes/{node}/qemu/{vmid}/agent/get-osinfo", "GET", agent_osinfo)
registry.register("/nodes/{node}/qemu/{vmid}/agent/get-host-name", "GET", agent_hostname)
registry.register(
"/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces", "GET", agent_network
)
registry.register("/nodes/{node}/qemu/{vmid}/agent/get-time", "GET", agent_time)
registry.register("/nodes/{node}/qemu/{vmid}/agent/ping", "POST", agent_ping)
registry.register("/nodes/{node}/qemu/{vmid}/feature", "GET", qemu_feature)
registry.register("/nodes/{node}/qemu/{vmid}/template", "POST", qemu_template)
registry.register("/nodes/{node}/tasks", "GET", task_list)
registry.register("/nodes/{node}/tasks/{upid}", "GET", task_index)
registry.register("/nodes/{node}/tasks/{upid}", "DELETE", task_delete)
registry.register("/nodes/{node}/tasks/{upid}/status", "GET", task_status)
registry.register("/nodes/{node}/tasks/{upid}/log", "GET", task_log)
from app.handlers.qemu_extra import register_qemu_extra_handlers
register_qemu_extra_handlers(registry)
async def _create_task(
request: Request,
*,
node: str,
vmid: str,
task_type: str,
payload: dict[str, Any],
) -> str:
database = _database(request)
worker_type = {
"qemu-create": "qmcreate",
"qemu-delete": "qmdestroy",
"qemu-update": "qmconfig",
"qemu-snapshot-create": "qmsnapshot",
"qemu-snapshot-delete": "qmdelsnapshot",
"qemu-snapshot-rollback": "qmrollback",
"qemu-clone": "qmclone",
"qemu-migrate": "qmigrate",
"qemu-remote-migrate": "qmremote",
"qemu-move-disk": "qmmove",
}[task_type]
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
try:
task = await TaskRepository(database.pool).create(
upid=upid,
task_type=task_type,
payload=payload,
resource_key=f"qemu:{vmid}",
idempotency_key=request.headers.get("Idempotency-Key"),
)
except ConflictError as error:
raise ApiError(409, str(error)) from error
return task.upid
async def _qemu_resource(request: Request, node: str, vmid: str) -> Any:
row = await _database(request).pool.fetchrow(
"""SELECT r.id, r.state, v.config FROM resources r
JOIN nodes n ON n.id=r.node_id
JOIN virtual_machines v ON v.resource_id=r.id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
node,
vmid,
)
if row is None:
raise ApiError(404, "virtual machine does not exist")
return row
async def _snapshot(request: Request, values: dict[str, Any]) -> Any:
row = await _database(request).pool.fetchrow(
"""SELECT s.* FROM snapshots s
JOIN resources r ON r.id=s.resource_id JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2 AND s.name=$3""",
str(values["node"]),
str(values["vmid"]),
str(values["snapname"]),
)
if row is None:
raise ApiError(404, "snapshot does not exist")
return row
async def _agent_resource(request: Request, values: dict[str, Any]) -> Any:
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
config = _state(resource["config"])
state = _state(resource["state"])
if str(config.get("agent", "0")).split(",", 1)[0].lower() not in {"1", "true", "yes"}:
raise ApiError(409, "QEMU guest agent is not enabled")
if state.get("status") != "running":
raise ApiError(409, "QEMU guest agent is not running")
return resource
_SIZE_RE = re.compile(r"^(?P<value>\d+)(?P<unit>[KMGT]?)$", re.IGNORECASE)
def _size_bytes(value: str) -> int:
match = _SIZE_RE.fullmatch(value.strip())
if match is None:
raise ApiError(400, f"invalid disk size: {value}")
units = {"": 1, "K": 2**10, "M": 2**20, "G": 2**30, "T": 2**40}
return int(match.group("value")) * units[match.group("unit").upper()]
def _disk_size_bytes(value: str) -> int:
for part in value.split(","):
if part.startswith("size="):
return _size_bytes(part.removeprefix("size="))
return 0
def _resize_bytes(value: str, current: int) -> int:
if value.startswith("+"):
return current + _size_bytes(value[1:])
result = _size_bytes(value)
if result < current:
raise ApiError(400, "shrinking disks is not supported")
return result
def _replace_disk_size(value: str, size: int) -> str:
parts = [part for part in value.split(",") if not part.startswith("size=")]
parts.append(f"size={size // 2**30}G" if size % 2**30 == 0 else f"size={size}")
return ",".join(parts)
+445
View File
@@ -0,0 +1,445 @@
"""Additional QEMU guest/agent/console endpoints with durable guest state."""
from __future__ import annotations
import json
import secrets
from collections.abc import Mapping
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.handlers.qemu import _agent_resource, _database, _qemu_resource, _state, _values
from app.security.auth import issue_ticket
def _settings(request: Request) -> Settings:
return cast(Settings, request.app.state.settings)
async def _save_guest_state(request: Request, resource_id: Any, state: dict[str, Any]) -> None:
await _database(request).pool.execute(
"UPDATE resources SET state=$2::jsonb, version=version+1, updated_at=now() WHERE id=$1",
resource_id,
json.dumps(state, sort_keys=True),
)
async def _save_guest_config(request: Request, resource_id: Any, config: dict[str, Any]) -> None:
await _database(request).pool.execute(
"UPDATE virtual_machines SET config=$2::jsonb WHERE resource_id=$1",
resource_id,
json.dumps(config, sort_keys=True),
)
def register_qemu_extra_handlers(registry: HandlerRegistry) -> None:
async def agent_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
await _agent_resource(request, _values(inputs))
return [
{"name": name}
for name in (
"exec",
"exec-status",
"file-read",
"file-write",
"fsfreeze-freeze",
"fsfreeze-status",
"fsfreeze-thaw",
"fstrim",
"get-fsinfo",
"get-memory-block-info",
"get-memory-blocks",
"get-timezone",
"get-users",
"get-vcpus",
"info",
"ping",
"set-user-password",
"shutdown",
"suspend-disk",
"suspend-hybrid",
"suspend-ram",
)
]
async def agent_post(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
command = str(payload.get("command") or "ping")
resource = await _agent_resource(request, payload)
state = _state(resource["state"])
agent = state.setdefault("agent", {})
agent["last_command"] = command
await _save_guest_state(request, resource["id"], state)
return {"result": {"command": command, "ok": 1}}
async def _agent_blob(command: str, request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
agent = state.setdefault("agent", {})
blobs = agent.setdefault("results", {})
defaults: dict[str, Any] = {
"get-users": [{"user": "root", "login-time": 0}],
"get-fsinfo": [{"name": "/", "type": "ext4", "total-bytes": 32 * 1024**3}],
"get-memory-block-info": {"size": 1024**3},
"get-memory-blocks": [{"start": 0, "size": 1024**3}],
"get-timezone": {"zone": "UTC", "offset": 0},
"get-vcpus": [{"online": True, "can-offline": False}],
"fsfreeze-status": "thawed",
}
if command not in blobs:
blobs[command] = defaults.get(command, {})
await _save_guest_state(request, resource["id"], state)
return {"result": blobs[command]}
async def agent_users(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-users", request, inputs)
async def agent_fsinfo(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-fsinfo", request, inputs)
async def agent_memory_block_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-memory-block-info", request, inputs)
async def agent_memory_blocks(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-memory-blocks", request, inputs)
async def agent_timezone(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-timezone", request, inputs)
async def agent_vcpus(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("get-vcpus", request, inputs)
async def agent_exec(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
state = _state(resource["state"])
agent = state.setdefault("agent", {})
execs = agent.setdefault("exec", {})
pid = int(agent.get("next_pid", 1000)) + 1
agent["next_pid"] = pid
execs[str(pid)] = {
"exited": 1,
"exitcode": 0,
"out-data": "",
"err-data": "",
"command": payload.get("command"),
}
await _save_guest_state(request, resource["id"], state)
return {"pid": pid}
async def agent_exec_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
pid = str(payload.get("pid") or "")
state = _state(resource["state"])
result = state.get("agent", {}).get("exec", {}).get(pid)
if not isinstance(result, dict):
raise ApiError(404, "exec process does not exist")
return {"result": result}
async def agent_file_read(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
path = str(payload.get("file") or payload.get("path") or "/etc/hostname")
state = _state(resource["state"])
files = state.setdefault("agent", {}).setdefault("files", {})
if path not in files:
files[path] = f"simulated:{path}\n"
await _save_guest_state(request, resource["id"], state)
content = str(files[path])
return {"result": {"content": content, "truncated": True}}
async def agent_file_write(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
default_path = "guest-agent-out"
path = str(payload.get("file") or payload.get("path") or default_path)
content = str(payload.get("content") or "")
state = _state(resource["state"])
files = state.setdefault("agent", {}).setdefault("files", {})
files[path] = content
await _save_guest_state(request, resource["id"], state)
return {"result": None}
async def agent_fsfreeze(
request: Request, inputs: dict[str, Any], status: str
) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
agent = state.setdefault("agent", {})
agent["fsfreeze"] = status
agent.setdefault("results", {})["fsfreeze-status"] = status
await _save_guest_state(request, resource["id"], state)
return {"result": status}
async def agent_fsfreeze_freeze(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_fsfreeze(request, inputs, "frozen")
async def agent_fsfreeze_thaw(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_fsfreeze(request, inputs, "thawed")
async def agent_fsfreeze_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _agent_blob("fsfreeze-status", request, inputs)
async def agent_fstrim(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
state.setdefault("agent", {})["last_fstrim"] = True
await _save_guest_state(request, resource["id"], state)
return {"result": {"paths": [{"path": "/", "trimmed": 0}]}}
async def agent_set_password(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = _values(inputs)
resource = await _agent_resource(request, payload)
username = str(payload.get("username") or "root")
state = _state(resource["state"])
passwords = state.setdefault("agent", {}).setdefault("passwords", {})
passwords[username] = True # store only presence, not secret
await _save_guest_state(request, resource["id"], state)
return {"result": None}
async def agent_shutdown(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
state["status"] = "stopped"
await _save_guest_state(request, resource["id"], state)
return {"result": None}
async def agent_suspend(request: Request, inputs: dict[str, Any], mode: str) -> dict[str, Any]:
resource = await _agent_resource(request, _values(inputs))
state = _state(resource["state"])
state["status"] = "paused"
state.setdefault("agent", {})["suspend"] = mode
await _save_guest_state(request, resource["id"], state)
return {"result": None}
async def agent_suspend_disk(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_suspend(request, inputs, "disk")
async def agent_suspend_ram(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_suspend(request, inputs, "ram")
async def agent_suspend_hybrid(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await agent_suspend(request, inputs, "hybrid")
async def cloudinit_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
config = _state(resource["config"])
state = _state(resource["state"])
pending = cast(Mapping[str, Any], state.get("pending", {}))
keys = sorted(
{
key
for key in set(config) | set(pending)
if str(key).startswith(("ci", "ipconfig", "sshkeys", "nameserver", "searchdomain"))
}
)
return [
{
"key": key,
"value": str(config.get(key, "")),
"pending": str(pending[key]) if key in pending else None,
}
for key in keys
]
async def cloudinit_update(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
state["cloudinit_generation"] = int(state.get("cloudinit_generation") or 0) + 1
await _save_guest_state(request, resource["id"], state)
async def cloudinit_dump(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
config = _state(resource["config"])
return (
f"#cloud-config\nhostname: {config.get('name', values['vmid'])}\n"
f"manage_etc_hosts: true\n"
)
async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
rrd_state = state.setdefault("rrd", {"filename": f"pve-vm-{values['vmid']}.rrd"})
await _save_guest_state(request, resource["id"], state)
return dict(rrd_state)
async def rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
series = state.setdefault(
"rrddata",
[
{
"time": 1_700_000_000,
"cpu": 0.05,
"mem": 256 * 1024 * 1024,
"netin": 0,
"netout": 0,
},
{
"time": 1_700_000_060,
"cpu": 0.08,
"mem": 260 * 1024 * 1024,
"netin": 100,
"netout": 80,
},
],
)
await _save_guest_state(request, resource["id"], state)
return list(series)
async def monitor(request: Request, inputs: dict[str, Any]) -> str:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
command = str(values.get("command") or "info status")
state = _state(resource["state"])
history = state.setdefault("monitor", [])
if not isinstance(history, list):
history = state["monitor"] = []
output = f"OK {command}"
history.append({"command": command, "output": output})
await _save_guest_state(request, resource["id"], state)
return output
async def sendkey(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
key = str(values.get("key") or "")
if not key:
raise ApiError(400, "parameter verification failed - 'key' missing")
state = _state(resource["state"])
keys = state.setdefault("sendkey", [])
if not isinstance(keys, list):
keys = state["sendkey"] = []
keys.append(key)
await _save_guest_state(request, resource["id"], state)
async def unlink(request: Request, inputs: dict[str, Any]) -> None:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
idlist = [
item.strip()
for item in str(values.get("idlist") or values.get("ids") or "").split(",")
if item.strip()
]
if not idlist:
raise ApiError(400, "parameter verification failed - 'idlist' missing")
config = _state(resource["config"])
for disk in idlist:
config.pop(disk, None)
await _save_guest_config(request, resource["id"], config)
state = _state(resource["state"])
state["config"] = config
await _save_guest_state(request, resource["id"], state)
async def _console_proxy(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
key = _settings(request).ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(str(request.state.principal), key)
port = 5900 + int(values["vmid"]) % 1000
state = _state(resource["state"])
consoles = state.setdefault("consoles", {})
payload = {
"type": kind,
"port": port,
"ticket": ticket,
"upid": (
f"UPID:{values['node']}:{secrets.token_hex(4)}:"
f"{kind}:{values['vmid']}:{request.state.principal}:"
),
"user": str(request.state.principal),
"cert": "",
}
if values.get("generate-password") or values.get("websocket"):
payload["password"] = secrets.token_urlsafe(8)
consoles[kind] = {k: v for k, v in payload.items() if k != "ticket"}
await _save_guest_state(request, resource["id"], state)
return payload
async def vncproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console_proxy(request, inputs, "vnc")
async def spiceproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console_proxy(request, inputs, "spice")
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console_proxy(request, inputs, "term")
async def mtunnel(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await _console_proxy(request, inputs, "mtunnel")
async def websocket_ticket(
request: Request, inputs: dict[str, Any], kind: str
) -> dict[str, Any]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
console = state.get("consoles", {}).get(kind) or {"port": 5900}
key = _settings(request).ticket_signing_key.get_secret_value().encode()
return {
"port": console.get("port", 5900),
"ticket": issue_ticket(str(request.state.principal), key),
}
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await websocket_ticket(request, inputs, "vnc")
async def mtunnelwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
return await websocket_ticket(request, inputs, "mtunnel")
async def dbus_vmstate(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = _values(inputs)
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
state = _state(resource["state"])
state["dbus_vmstate"] = True
await _save_guest_state(request, resource["id"], state)
return {"result": "OK"}
base = "/nodes/{node}/qemu/{vmid}"
registry.register(f"{base}/agent", "GET", agent_index)
registry.register(f"{base}/agent", "POST", agent_post)
registry.register(f"{base}/agent/exec", "POST", agent_exec)
registry.register(f"{base}/agent/exec-status", "GET", agent_exec_status)
registry.register(f"{base}/agent/file-read", "GET", agent_file_read)
registry.register(f"{base}/agent/file-write", "POST", agent_file_write)
registry.register(f"{base}/agent/fsfreeze-freeze", "POST", agent_fsfreeze_freeze)
registry.register(f"{base}/agent/fsfreeze-status", "POST", agent_fsfreeze_status)
registry.register(f"{base}/agent/fsfreeze-thaw", "POST", agent_fsfreeze_thaw)
registry.register(f"{base}/agent/fstrim", "POST", agent_fstrim)
registry.register(f"{base}/agent/get-fsinfo", "GET", agent_fsinfo)
registry.register(f"{base}/agent/get-memory-block-info", "GET", agent_memory_block_info)
registry.register(f"{base}/agent/get-memory-blocks", "GET", agent_memory_blocks)
registry.register(f"{base}/agent/get-timezone", "GET", agent_timezone)
registry.register(f"{base}/agent/get-users", "GET", agent_users)
registry.register(f"{base}/agent/get-vcpus", "GET", agent_vcpus)
registry.register(f"{base}/agent/set-user-password", "POST", agent_set_password)
registry.register(f"{base}/agent/shutdown", "POST", agent_shutdown)
registry.register(f"{base}/agent/suspend-disk", "POST", agent_suspend_disk)
registry.register(f"{base}/agent/suspend-hybrid", "POST", agent_suspend_hybrid)
registry.register(f"{base}/agent/suspend-ram", "POST", agent_suspend_ram)
registry.register(f"{base}/cloudinit", "GET", cloudinit_get)
registry.register(f"{base}/cloudinit", "PUT", cloudinit_update)
registry.register(f"{base}/cloudinit/dump", "GET", cloudinit_dump)
registry.register(f"{base}/rrd", "GET", rrd)
registry.register(f"{base}/rrddata", "GET", rrddata)
registry.register(f"{base}/monitor", "POST", monitor)
registry.register(f"{base}/sendkey", "PUT", sendkey)
registry.register(f"{base}/unlink", "PUT", unlink)
registry.register(f"{base}/vncproxy", "POST", vncproxy)
registry.register(f"{base}/spiceproxy", "POST", spiceproxy)
registry.register(f"{base}/termproxy", "POST", termproxy)
registry.register(f"{base}/mtunnel", "POST", mtunnel)
registry.register(f"{base}/vncwebsocket", "GET", vncwebsocket)
registry.register(f"{base}/mtunnelwebsocket", "GET", mtunnelwebsocket)
registry.register(f"{base}/dbus-vmstate", "POST", dbus_vmstate)
+1027
View File
File diff suppressed because it is too large Load Diff
+576
View File
@@ -0,0 +1,576 @@
"""Storage semantic handlers."""
from __future__ import annotations
import json
from typing import Any
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.handlers.common import database, require_node, state, storage_payload, subdirs, values
from app.simulation.seed import CLUSTER_ID, stable_id
def register_storage_handlers(registry: HandlerRegistry) -> None:
async def _storage_row(request: Request, node: str | None, storage_id: str) -> Any:
row = await database(request).pool.fetchrow(
"""SELECT s.storage_id, s.storage_type, s.shared, s.capacity_bytes, s.used_bytes,
s.config, n.name AS node_name
FROM storages s
JOIN resources r ON r.id = s.resource_id
JOIN nodes n ON n.id = r.node_id
WHERE s.storage_id=$1 AND ($2::text IS NULL OR n.name=$2)""",
storage_id,
node,
)
if row is None:
raise ApiError(404, "storage does not exist")
return row
async def storage_ids(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
rows = await database(_request).pool.fetch(
"SELECT DISTINCT storage_id FROM storages ORDER BY storage_id"
)
return [{"storage": str(row["storage_id"])} for row in rows]
async def storage_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
storage_id = str(payload["storage"])
storage_type = str(payload.get("type") or "dir")
exists = await database(request).pool.fetchval(
"SELECT EXISTS(SELECT 1 FROM storages WHERE storage_id=$1)",
storage_id,
)
if exists:
raise ApiError(409, "storage ID already exists")
node = await database(request).pool.fetchrow(
"SELECT id, name FROM nodes ORDER BY name LIMIT 1"
)
if node is None:
raise ApiError(503, "no nodes available")
resource_id = stable_id(f"storage:{storage_id}")
config = {
key: value
for key, value in payload.items()
if key not in {"storage", "type", "nodes", "delete"}
}
if "content" in payload:
config["content"] = [
item.strip() for item in str(payload["content"]).split(",") if item.strip()
]
async with database(request).pool.acquire() as connection:
async with connection.transaction():
await connection.execute(
"""INSERT INTO resources(id, node_id, kind, external_id, state, cluster_id)
VALUES($1, $2, 'storage', $3, $4::jsonb, $5)""",
resource_id,
node["id"],
storage_id,
json.dumps({**config, "status": "available"}, sort_keys=True),
CLUSTER_ID,
)
await connection.execute(
"""INSERT INTO storages(
resource_id, cluster_id, storage_id, storage_type, shared, config
) VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
resource_id,
CLUSTER_ID,
storage_id,
storage_type,
bool(payload.get("shared", False)),
json.dumps(config, sort_keys=True),
)
return {"storage": storage_id, "type": storage_type, "config": config}
async def storage_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
storage_id = str(values(inputs)["storage"])
row = await _storage_row(request, None, storage_id)
config = state(row["config"])
return {
"storage": storage_id,
"type": str(row["storage_type"]),
"shared": int(bool(row["shared"])),
**config,
}
async def storage_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
storage_id = str(values(inputs)["storage"])
row = await database(request).pool.fetchrow(
"""SELECT s.resource_id, s.config FROM storages s WHERE s.storage_id=$1""",
storage_id,
)
if row is None:
raise ApiError(404, "storage does not exist")
current = state(row["config"])
provided = values(inputs)
updated = {
**current,
**{
key: value
for key, value in provided.items()
if key not in {"storage", "delete", "digest"}
},
}
await database(request).pool.execute(
"UPDATE storages SET config=$2::jsonb WHERE storage_id=$1",
storage_id,
json.dumps(updated, sort_keys=True),
)
return updated
async def storage_delete(request: Request, inputs: dict[str, Any]) -> None:
storage_id = str(values(inputs)["storage"])
status = await database(request).pool.execute(
"""DELETE FROM resources r USING storages s
WHERE s.resource_id=r.id AND s.storage_id=$1""",
storage_id,
)
if status != "DELETE 1":
raise ApiError(404, "storage does not exist")
async def node_storage_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
await require_node(request, node)
rows = await database(request).pool.fetch(
"""SELECT s.storage_id, s.storage_type, s.shared,
s.capacity_bytes, s.used_bytes, s.config
FROM storages s
JOIN resources r ON r.id = s.resource_id
JOIN nodes n ON n.id = r.node_id
WHERE n.name=$1 OR s.shared = true
ORDER BY s.storage_id""",
node,
)
return [storage_payload(row) for row in rows]
async def node_storage_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
return subdirs("content", "status", "upload")
async def node_storage_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
row = await _storage_row(request, None, storage_id)
return storage_payload(row)
async def node_storage_content(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
contents = await database(request).pool.fetch(
"""SELECT volume_id, content_type, size_bytes, metadata, created_at
FROM storage_contents WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
resource_id,
)
backups = await database(request).pool.fetch(
"""SELECT b.volume_id, b.size_bytes, b.metadata, b.created_at, r.external_id AS vmid
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.storage_resource_id=$1
ORDER BY b.created_at DESC""",
resource_id,
)
result: list[dict[str, Any]] = []
for item in contents:
metadata = state(item["metadata"])
result.append(
{
"volid": str(item["volume_id"]),
"content": str(item["content_type"]),
"size": int(item["size_bytes"]),
"format": metadata.get("format", "raw"),
"ctime": int(item["created_at"].timestamp()),
}
)
for item in backups:
metadata = state(item["metadata"])
result.append(
{
"volid": str(item["volume_id"]),
"content": "backup",
"size": int(item["size_bytes"]),
"format": "vma.zst",
"vmid": int(item["vmid"]) if item["vmid"] is not None else None,
"notes": metadata.get("notes-template"),
"ctime": int(item["created_at"].timestamp()),
}
)
return result
async def _content_item(
request: Request, storage_resource_id: object, volume_id: str
) -> dict[str, Any]:
row = await database(request).pool.fetchrow(
"""SELECT volume_id, content_type, size_bytes, metadata, created_at
FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2""",
storage_resource_id,
volume_id,
)
if row is not None:
metadata = state(row["metadata"])
return {
"volid": str(row["volume_id"]),
"content": str(row["content_type"]),
"size": int(row["size_bytes"]),
"format": metadata.get("format", "raw"),
"ctime": int(row["created_at"].timestamp()),
}
backup = await database(request).pool.fetchrow(
"""SELECT b.volume_id, b.size_bytes, b.metadata, b.created_at, r.external_id AS vmid
FROM backups b
LEFT JOIN resources r ON r.id = b.resource_id
WHERE b.storage_resource_id=$1 AND b.volume_id=$2""",
storage_resource_id,
volume_id,
)
if backup is None:
raise ApiError(404, "volume does not exist")
metadata = state(backup["metadata"])
return {
"volid": str(backup["volume_id"]),
"content": "backup",
"size": int(backup["size_bytes"]),
"format": "vma.zst",
"vmid": int(backup["vmid"]) if backup["vmid"] is not None else None,
"notes": metadata.get("notes-template"),
"ctime": int(backup["created_at"].timestamp()),
}
async def node_storage_content_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
volume_id = str(values(inputs)["volume"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
return await _content_item(request, resource_id, volume_id)
async def node_storage_content_delete(request: Request, inputs: dict[str, Any]) -> None:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
volume_id = str(values(inputs)["volume"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
status = await database(request).pool.execute(
"DELETE FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2",
resource_id,
volume_id,
)
if status == "DELETE 1":
return
status = await database(request).pool.execute(
"DELETE FROM backups WHERE storage_resource_id=$1 AND volume_id=$2",
resource_id,
volume_id,
)
if status != "DELETE 1":
raise ApiError(404, "volume does not exist")
async def node_storage_upload(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
payload = values(inputs)
filename = str(payload.get("filename") or "upload.bin")
content_type = str(payload.get("content") or "iso")
raw_size = payload.get("size") or 0
try:
size = int(raw_size)
except (TypeError, ValueError) as error:
raise ApiError(400, "invalid size") from error
volume_id = f"{storage_id}:{content_type}/{filename}"
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, $3, $4, $5::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET size_bytes=EXCLUDED.size_bytes,
content_type=EXCLUDED.content_type,
metadata=EXCLUDED.metadata""",
resource_id,
volume_id,
content_type,
size,
json.dumps({"filename": filename, "source": "upload"}, sort_keys=True),
)
return {"uploadid": volume_id, "filename": filename, "size": size, "volid": volume_id}
async def node_storage_prunebackups(
request: Request, inputs: dict[str, Any]
) -> list[dict[str, Any]] | None:
node = str(values(inputs)["node"])
storage_id = str(values(inputs)["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
if request.method == "DELETE":
keep = int(values(inputs).get("keep-last") or values(inputs).get("keep_last") or 1)
await database(request).pool.execute(
"""DELETE FROM backups
WHERE storage_resource_id=$1 AND id IN (
SELECT id FROM backups
WHERE storage_resource_id=$1
ORDER BY created_at DESC
OFFSET $2
)""",
resource_id,
keep,
)
return None
rows = await database(request).pool.fetch(
"""SELECT volume_id, size_bytes, created_at FROM backups
WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
resource_id,
)
return [
{
"volid": str(row["volume_id"]),
"size": int(row["size_bytes"]),
"ctime": int(row["created_at"].timestamp()),
}
for row in rows
]
async def content_copy(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
row = await database(request).pool.fetchrow(
"""SELECT volume_id, content_type, size_bytes, metadata
FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2""",
resource_id,
volume,
)
if row is None:
raise ApiError(404, "volume does not exist")
target = str(payload.get("target") or f"{volume}-copy")
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, $3, $4, $5::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET size_bytes=EXCLUDED.size_bytes, metadata=EXCLUDED.metadata""",
resource_id,
target,
row["content_type"],
row["size_bytes"],
json.dumps({**state(row["metadata"]), "copied_from": volume}, sort_keys=True),
)
return f"UPID:{node}:copy:{target}"
async def content_update(request: Request, inputs: dict[str, Any]) -> None:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await require_node(request, node)
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
row = await database(request).pool.fetchrow(
"""SELECT metadata FROM storage_contents
WHERE storage_resource_id=$1 AND volume_id=$2""",
resource_id,
volume,
)
if row is None:
raise ApiError(404, "volume does not exist")
meta = state(row["metadata"])
if "notes" in payload:
meta["notes"] = payload["notes"]
if "protected" in payload:
meta["protected"] = int(bool(payload["protected"]))
await database(request).pool.execute(
"""UPDATE storage_contents SET metadata=$3::jsonb
WHERE storage_resource_id=$1 AND volume_id=$2""",
resource_id,
volume,
json.dumps(meta, sort_keys=True),
)
async def download_url(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
filename = str(payload.get("filename") or "download.bin")
content_type = str(payload.get("content") or "iso")
volume_id = f"{storage_id}:{content_type}/{filename}"
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, $3, 0, $4::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET metadata=EXCLUDED.metadata""",
resource_id,
volume_id,
content_type,
json.dumps(
{
"filename": filename,
"url": payload.get("url"),
"source": "download-url",
},
sort_keys=True,
),
)
return f"UPID:{node}:download:{filename}"
async def oci_pull(request: Request, inputs: dict[str, Any]) -> str:
payload = values(inputs)
node = str(payload["node"])
storage_id = str(payload["storage"])
await require_node(request, node)
await _storage_row(request, None, storage_id)
reference = str(payload.get("reference") or "image:latest")
filename = str(payload.get("filename") or reference.replace("/", "_"))
volume_id = f"{storage_id}:import/{filename}"
resource_id = await database(request).pool.fetchval(
"SELECT resource_id FROM storages WHERE storage_id=$1",
storage_id,
)
await database(request).pool.execute(
"""INSERT INTO storage_contents(
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
) VALUES(gen_random_uuid(), $1, $2, 'import', 0, $3::jsonb)
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
SET metadata=EXCLUDED.metadata""",
resource_id,
volume_id,
json.dumps({"reference": reference, "source": "oci"}, sort_keys=True),
)
return f"UPID:{node}:oci-pull:{filename}"
async def file_restore_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
filepath = str(payload.get("filepath") or "/")
return [{"filepath": filepath.rstrip("/") + "/etc", "type": "d", "text": "etc"}]
async def file_restore_download(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
return {
"download-url": f"/api2/json/nodes/{payload['node']}/storage/"
f"{payload['storage']}/file-restore/download",
"filepath": payload.get("filepath") or "/",
"volume": payload.get("volume"),
}
async def storage_identity(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
row = await _storage_row(request, None, str(payload["storage"]))
return {
"storage": str(row["storage_id"]),
"type": str(row["storage_type"]),
"fingerprint": f"sim-{row['storage_id']}",
}
async def import_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
storage_id = str(payload["storage"])
volume = str(payload["volume"])
await _storage_row(request, None, storage_id)
return {
"type": "qemu",
"source": volume,
"disks": {"scsi0": f"{storage_id}:0/vm-import.raw"},
"net0": "virtio,bridge=vmbr0",
}
async def storage_rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
storage_id = str(payload["storage"])
await _storage_row(request, None, storage_id)
return {"filename": f"pve-storage-{storage_id}.rrd"}
async def storage_rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
payload = values(inputs)
await require_node(request, str(payload["node"]))
await _storage_row(request, None, str(payload["storage"]))
return [
{"time": 1_700_000_000, "used": 10, "total": 100},
{"time": 1_700_000_060, "used": 12, "total": 100},
]
registry.register("/storage", "GET", storage_ids)
registry.register("/storage", "POST", storage_create)
registry.register("/storage/{storage}", "GET", storage_get)
registry.register("/storage/{storage}", "PUT", storage_update)
registry.register("/storage/{storage}", "DELETE", storage_delete)
registry.register("/nodes/{node}/storage", "GET", node_storage_list)
registry.register("/nodes/{node}/storage/{storage}", "GET", node_storage_index)
registry.register("/nodes/{node}/storage/{storage}/status", "GET", node_storage_status)
registry.register("/nodes/{node}/storage/{storage}/content", "GET", node_storage_content)
registry.register("/nodes/{node}/storage/{storage}/content", "POST", node_storage_upload)
registry.register(
"/nodes/{node}/storage/{storage}/content/{volume}", "GET", node_storage_content_get
)
registry.register(
"/nodes/{node}/storage/{storage}/content/{volume}", "DELETE", node_storage_content_delete
)
registry.register("/nodes/{node}/storage/{storage}/upload", "POST", node_storage_upload)
registry.register(
"/nodes/{node}/storage/{storage}/prunebackups", "GET", node_storage_prunebackups
)
registry.register(
"/nodes/{node}/storage/{storage}/prunebackups", "DELETE", node_storage_prunebackups
)
registry.register("/nodes/{node}/storage/{storage}/content/{volume}", "POST", content_copy)
registry.register("/nodes/{node}/storage/{storage}/content/{volume}", "PUT", content_update)
registry.register("/nodes/{node}/storage/{storage}/download-url", "POST", download_url)
registry.register("/nodes/{node}/storage/{storage}/oci-registry-pull", "POST", oci_pull)
registry.register("/nodes/{node}/storage/{storage}/file-restore/list", "GET", file_restore_list)
registry.register(
"/nodes/{node}/storage/{storage}/file-restore/download", "GET", file_restore_download
)
registry.register("/nodes/{node}/storage/{storage}/identity", "GET", storage_identity)
registry.register("/nodes/{node}/storage/{storage}/import-metadata", "GET", import_metadata)
registry.register("/nodes/{node}/storage/{storage}/rrd", "GET", storage_rrd)
registry.register("/nodes/{node}/storage/{storage}/rrddata", "GET", storage_rrddata)