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:
@@ -0,0 +1 @@
|
||||
"""Semantic handlers for implemented Proxmox methods."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
Reference in New Issue
Block a user