Files
inecs e8b08526d1 Keep API not-found and 401 responses usable behind Ingress and in the Web UI.
Return native JSON with the missing id in the message, clear stale sessions on
401, and document ingress-nginx annotations so branded HTML 404/405 pages do
not rewrite simulator bodies.
2026-07-22 06:52:35 +03:00

540 lines
18 KiB
Python

"""Permissions, identity, guest extras, host detail, console tickets."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Response
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere import inventory
from app.vsphere.domain import vm_ops
from app.vsphere.errors import invalid_argument, not_found
from app.vsphere.security.authz import require_admin, require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Platform"])
@router.get("/api/vcenter/privilege")
async def list_privileges(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, str]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/privilege")
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/authorization/roles")
async def list_roles(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/authorization/roles")
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/authorization/permissions")
async def list_permissions(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, principal, role, entity_moid, propagate FROM vsphere_permissions ORDER BY id"
)
return [
{
"id": row["id"],
"principal": row["principal"],
"role": row["role"],
"entity": row["entity_moid"],
"propagate": row["propagate"],
}
for row in rows
]
@router.post("/api/vcenter/authorization/permissions")
async def create_permission(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
principal = str(body.get("principal") or "").strip()
role = str(body.get("role") or "").strip()
if not principal or not role:
raise invalid_argument("principal and role are required")
entity = body.get("entity") or body.get("entity_moid")
propagate = bool(body.get("propagate", True))
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO vsphere_permissions (principal, role, entity_moid, propagate)
VALUES ($1, $2, $3, $4)
RETURNING id
""",
principal,
role,
None if entity in (None, "", "null") else str(entity),
propagate,
)
return {"id": row["id"]}
@router.delete("/api/vcenter/authorization/permissions/{permission_id}")
async def delete_permission(
permission_id: int,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> Response:
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM vsphere_permissions WHERE id = $1",
permission_id,
)
if not result.endswith("1"):
raise not_found(f"Permission {permission_id} not found")
return Response(status_code=204)
@router.get("/api/vcenter/namespaces")
async def list_namespaces(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/namespaces")
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/namespace-management/nsx-tier0-gateway")
async def nsx_tier0_gateway(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
path = "/api/vcenter/namespace-management/nsx-tier0-gateway"
payload = await api_state.get_payload(database, "GET", path)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/namespace-management/networks")
async def wcp_networks(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
path = "/api/vcenter/namespace-management/networks"
payload = await api_state.get_payload(database, "GET", path)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/namespace-management/virtual-machine-classes")
async def vm_classes(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
path = "/api/vcenter/namespace-management/virtual-machine-classes"
payload = await api_state.get_payload(database, "GET", path)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/identity/providers")
async def identity_providers(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import platform_surface
return await platform_surface.list_identity_providers(database)
@router.post("/api/vcenter/identity/providers")
async def create_identity_provider(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
from app.vsphere.domain import platform_surface
return await platform_surface.upsert_identity_provider(database, body)
@router.get("/api/vcenter/identity/providers/{provider}")
async def get_identity_provider(
provider: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
from app.vsphere.domain import platform_surface
item = await platform_surface.get_identity_provider(database, provider)
if item is not None:
return item
raise not_found(f"Identity provider {provider} not found")
@router.patch("/api/vcenter/identity/providers/{provider}")
@router.put("/api/vcenter/identity/providers/{provider}")
async def update_identity_provider(
provider: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
from app.vsphere.domain import platform_surface
payload = {**body, "provider": provider}
return await platform_surface.upsert_identity_provider(database, payload)
@router.delete("/api/vcenter/identity/providers/{provider}")
async def delete_identity_provider(
provider: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> Response:
from app.vsphere.domain import platform_surface
if not await platform_surface.delete_identity_provider(database, provider):
raise not_found(f"Identity provider {provider} not found")
return Response(status_code=204)
@router.get("/api/vcenter/certificate-management/vcenter/tls")
async def machine_tls(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(
database, "GET", "/api/vcenter/certificate-management/vcenter/tls"
)
return payload if isinstance(payload, dict) else {}
@router.get("/api/vcenter/certificate-management/vcenter/tls-csr")
async def machine_tls_csr_get(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(
database, "GET", "/api/vcenter/certificate-management/vcenter/tls-csr"
)
return payload if isinstance(payload, dict) else {}
@router.post("/api/vcenter/certificate-management/vcenter/tls-csr")
async def machine_tls_csr_create(
body: dict[str, Any] | None = None,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
from app.vsphere.domain import api_state
del body
payload = await api_state.get_payload(
database, "GET", "/api/vcenter/certificate-management/vcenter/tls-csr"
)
return payload if isinstance(payload, dict) else {}
@router.get("/api/vcenter/certificate-management/vcenter/trusted-root-chains")
async def trusted_root_chains(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(
database, "GET", "/api/vcenter/certificate-management/vcenter/trusted-root-chains"
)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/vm/{vm}/guest/customization")
async def guest_customization_get(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
customization = obj.props.get("customization")
if isinstance(customization, dict) and customization:
return customization
# Seed / probe may wipe the field with POST {}; keep a non-empty lab view.
return {
"name": obj.name,
"status": "PENDING",
"spec": {"hostname": obj.name, "domain": "lab.local"},
}
@router.get("/api/vcenter/vm/{vm}/guest/networking")
async def guest_networking(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await vm_ops.guest_networking(database, vm)
@router.get("/api/vcenter/vm/{vm}/guest/power")
async def guest_power_get(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, str]:
obj = await vm_ops.require_vm(database, vm)
state = str(obj.props.get("power_state") or "POWERED_OFF")
return {"state": "RUNNING" if state == "POWERED_ON" else "NOT_RUNNING"}
@router.post("/api/vcenter/vm/{vm}/guest/power")
async def guest_power_post(
vm: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.PowerOn")),
) -> Response:
mapping = {"reboot": "reset", "shutdown": "stop", "standby": "suspend"}
await vm_ops.set_power(database, vm, mapping.get(action, action))
return Response(status_code=204)
@router.post("/api/vcenter/vm/{vm}/tools")
async def tools_upgrade(
vm: str,
action: str = Query("upgrade"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.DeviceConnection")),
) -> dict[str, str]:
del action
await vm_ops.require_vm(database, vm)
from app.vsphere.domain import tasks as task_store
task_id = await task_store.create_task(
database,
description=f"Upgrade tools {vm}",
service="com.vmware.vcenter.vm.tools",
operation="upgrade",
result={"vm": vm},
)
return {"task": task_id}
@router.post("/api/vcenter/vm/{vm}/console/tickets")
async def console_tickets(
vm: str,
body: dict[str, Any] | None = None,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.ConsoleInteract")),
) -> dict[str, Any]:
del body
return await vm_ops.console_ticket(database, vm)
@router.post("/api/vcenter/vm/{vm}/guest/customization")
async def guest_customization(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.Rename")),
) -> dict[str, str]:
obj = await vm_ops.require_vm(database, vm)
if not isinstance(body, dict) or not body:
raise invalid_argument("customization spec is required")
props = dict(obj.props)
props["customization"] = body
await inventory.update_props(database, vm, props)
from app.vsphere.domain import tasks as task_store
task_id = await task_store.create_task(
database,
description=f"Customize {vm}",
service="com.vmware.vcenter.vm.guest.customization",
operation="set",
result={"vm": vm},
)
return {"task": task_id}
@router.get("/api/vcenter/vm/{vm}/guest/local-filesystem")
async def guest_local_filesystem(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
filesystems = obj.props.get("guest_filesystems")
if isinstance(filesystems, dict) and filesystems:
return filesystems
return {
"filesystems": {
"/": {"capacity": 42949672960, "free_space": 21474836480},
}
}
@router.get("/api/vcenter/vm/{vm}/guest/filesystem")
async def guest_filesystem_get(
vm: str,
path: str = Query("/"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
if path in {"/", "/tmp", "/etc"}:
entries = await vm_ops.guest_list_files(database, vm, path)
return {"path": path, "type": "DIRECTORY", "files": entries}
content = await vm_ops.guest_read_file(database, vm, path)
return {"path": path, "type": "FILE", "content": content, "size": len(content.encode("utf-8"))}
@router.put("/api/vcenter/vm/{vm}/guest/filesystem")
async def guest_filesystem_put(
vm: str,
body: dict[str, Any],
path: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.DeviceConnection")),
) -> Response:
content = str(body.get("content") or "")
await vm_ops.guest_write_file(database, vm, path, content, overwrite=True)
return Response(status_code=204)
@router.delete("/api/vcenter/vm/{vm}/guest/filesystem")
async def guest_filesystem_delete(
vm: str,
path: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.DeviceConnection")),
) -> Response:
await vm_ops.guest_delete_file(database, vm, path)
return Response(status_code=204)
@router.get("/api/vcenter/vm/{vm}/guest/filesystem/files")
async def guest_filesystem_list(
vm: str,
path: str = Query("/"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
return await vm_ops.guest_list_files(database, vm, path)
@router.post("/api/vcenter/vm/{vm}")
async def vm_action(
vm: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.MarkAsTemplate")),
) -> dict[str, str]:
if action == "mark-as-template":
task_id = await vm_ops.set_template(database, vm, template=True)
elif action in {"mark-as-vm", "mark-as-virtual-machine"}:
task_id = await vm_ops.set_template(database, vm, template=False)
elif action == "unregister":
task_id = await vm_ops.unregister_vm(database, vm)
else:
raise invalid_argument(f"unsupported action {action}")
return {"task": task_id}
@router.get("/api/vcenter/host/{host}/storage/storage-device")
async def host_storage(
host: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
devices = obj.props.get("storage_devices")
return list(devices) if isinstance(devices, list) else []
@router.get("/api/vcenter/host/{host}/networking")
async def host_networking(
host: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
networking = obj.props.get("networking")
return networking if isinstance(networking, dict) else {}
@router.get("/api/vcenter/folder/{folder}/children")
async def folder_children(
folder: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, str]]:
await inventory.require_object(database, folder, resource="folder")
children = [obj for obj in await inventory.list_objects(database) if obj.parent_moid == folder]
return [{"moid": c.moid, "type": c.type, "name": c.name} for c in children]
@router.get("/api/vcenter/storage/policies/{policy}/vm")
async def policy_vms(
policy: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
objects = await inventory.list_objects(database, type_name="VirtualMachine")
if policy in {"policy-default", "default"}:
return [obj.moid for obj in objects]
# Named non-default policies attach to every 10th VM for lab demos.
return [obj.moid for i, obj in enumerate(objects) if i % 10 == 0]
@router.get("/api/vapi/metadata/metamodel/service")
async def metamodel_services(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vapi/metadata/metamodel/service")
return payload if isinstance(payload, list) else []
@router.get("/api/vapi/metadata/authentication/component")
async def authentication_components(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(
database, "GET", "/api/vapi/metadata/authentication/component"
)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/activity-history")
async def activity_history(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/activity-history")
return payload if isinstance(payload, list) else []