Files
vmware-api-simulator/app/vsphere/rest/router.py
T
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

441 lines
16 KiB
Python

"""vSphere Automation REST API (/api + legacy /rest)."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBasicCredentials
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, unauthenticated
from app.vsphere.rest import mappers
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import (
SESSION_HEADER,
SessionInfo,
create_session,
delete_session,
ensure_default_credentials,
optional_basic,
require_session,
verify_password,
)
router = APIRouter(tags=["vSphere REST"])
def _session_json(session_id: str, *, legacy: bool = False) -> JSONResponse:
# Modern /api/session returns a JSON string; legacy /rest returns {value: ...}.
body: Any = {"value": session_id} if legacy else session_id
return JSONResponse(content=body, status_code=201 if not legacy else 200)
def _attach_session_cookie(response: Response, session_id: str) -> None:
response.headers[SESSION_HEADER] = session_id
response.set_cookie(
key=SESSION_HEADER,
value=session_id,
httponly=False,
samesite="strict",
path="/",
max_age=(2 * 60 * 60),
)
@router.post("/api/session")
async def create_api_session(
request: Request,
database: Database = Depends(get_database),
credentials: HTTPBasicCredentials | None = Depends(optional_basic),
) -> Response:
await ensure_default_credentials(database)
username, password = await _credentials_from_request(request, credentials)
if not await verify_password(database, username, password):
raise unauthenticated("Invalid credentials")
session_id = await create_session(database, username)
response = _session_json(session_id)
_attach_session_cookie(response, session_id)
return response
@router.delete("/api/session")
async def delete_api_session(
session: SessionInfo = Depends(require_session),
database: Database = Depends(get_database),
) -> Response:
await delete_session(database, session.id)
response = Response(status_code=204)
response.delete_cookie(SESSION_HEADER, path="/")
return response
@router.get("/api/session")
async def get_api_session(session: SessionInfo = Depends(require_session)) -> Response:
# vSphere Automation: validate session with HTTP 200 and empty body.
# Lab helpers expose identity via optional headers for the web console.
response = Response(status_code=200)
response.headers["x-vmware-session-user"] = session.username
response.headers["x-vmware-session-roles"] = ",".join(session.roles)
return response
@router.post("/rest/com/vmware/cis/session")
async def create_legacy_session(
request: Request,
database: Database = Depends(get_database),
credentials: HTTPBasicCredentials | None = Depends(optional_basic),
) -> Response:
await ensure_default_credentials(database)
username, password = await _credentials_from_request(request, credentials)
if not await verify_password(database, username, password):
raise unauthenticated("Invalid credentials")
session_id = await create_session(database, username)
response = _session_json(session_id, legacy=True)
_attach_session_cookie(response, session_id)
return response
@router.get("/rest/com/vmware/cis/session")
async def get_legacy_session(session: SessionInfo = Depends(require_session)) -> dict[str, str]:
return {"value": session.id}
@router.delete("/rest/com/vmware/cis/session")
async def delete_legacy_session(
session: SessionInfo = Depends(require_session),
database: Database = Depends(get_database),
) -> dict[str, Any]:
await delete_session(database, session.id)
return {"value": None}
@router.get("/api/appliance/system/version")
async def appliance_version(
request: Request,
database: Database = Depends(get_database),
) -> dict[str, Any]:
"""Lab-friendly: version is readable without a session (real vCenter varies)."""
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/appliance/system/version")
if isinstance(payload, dict) and payload:
# Overlay active runtime major when contract apply changed it.
major = getattr(request.app.state, "runtime_source_version", None)
if major:
out = dict(payload)
out["version"] = str(major)
return out
return payload
return {}
@router.get("/api/vcenter/vm")
async def list_vms(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
names: list[str] | None = Query(default=None),
power_states: list[str] | None = Query(default=None),
hosts: list[str] | None = Query(default=None),
folders: list[str] | None = Query(default=None),
datacenters: list[str] | None = Query(default=None),
clusters: list[str] | None = Query(default=None),
resource_pools: list[str] | None = Query(default=None),
limit: int | None = Query(default=None, ge=1, le=5000),
cursor: int = Query(default=0, ge=0),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="VirtualMachine")
result_objs = objects
if names:
wanted = set(names)
result_objs = [obj for obj in result_objs if obj.name in wanted]
if power_states:
wanted_states = set(power_states)
result_objs = [obj for obj in result_objs if obj.props.get("power_state") in wanted_states]
if hosts:
host_set = set(hosts)
result_objs = [obj for obj in result_objs if obj.props.get("host") in host_set]
if folders:
folder_set = set(folders)
result_objs = [obj for obj in result_objs if obj.parent_moid in folder_set]
if resource_pools:
rp_set = set(resource_pools)
result_objs = [obj for obj in result_objs if obj.props.get("resource_pool") in rp_set]
if clusters or datacenters:
all_objects = {obj.moid: obj for obj in await inventory.list_objects(database)}
if clusters:
cluster_set = set(clusters)
result_objs = [
obj
for obj in result_objs
if all_objects.get(str(obj.props.get("host") or ""), None)
and all_objects[str(obj.props.get("host"))].parent_moid in cluster_set
]
if datacenters:
dc_set = set(datacenters)
filtered = []
for obj in result_objs:
parent = all_objects.get(obj.parent_moid or "")
grand = (
all_objects.get(parent.parent_moid) if parent and parent.parent_moid else None
)
if (parent and parent.moid in dc_set) or (grand and grand.moid in dc_set):
filtered.append(obj)
result_objs = filtered
if limit is not None:
result_objs = result_objs[cursor : cursor + limit]
elif cursor:
result_objs = result_objs[cursor:]
return [mappers.vm_summary(obj) for obj in result_objs]
@router.get("/api/vcenter/vm/{vm}")
async def get_vm(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
return mappers.vm_info(obj)
@router.post("/api/vcenter/vm")
async def create_vm(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Create")),
) -> str:
name = str(body.get("name") or "").strip()
if not name:
raise invalid_argument("name is required")
guest = body.get("guest_OS") or body.get("guest_os") or "OTHER_GUEST_64"
placement = body.get("placement") or {}
folder = placement.get("folder") or "group-v23"
host = placement.get("host") or "host-11"
datastore = placement.get("datastore") or "datastore-31"
pool = placement.get("resource_pool") or placement.get("cluster") or "resgroup-22"
cpu = int((body.get("cpu") or {}).get("count") or body.get("cpu_count") or 1)
memory = int((body.get("memory") or {}).get("size_MiB") or body.get("memory_size_MiB") or 1024)
disks_spec = body.get("disks") or body.get("disk")
nics_spec = body.get("nics") or body.get("ethernet")
networks = body.get("networks") or ["network-41"]
disks = None
if isinstance(disks_spec, list) and disks_spec:
disks = []
for idx, disk in enumerate(disks_spec):
value = disk.get("new_vmdk") or disk.get("value") or disk
capacity = int(value.get("capacity") or value.get("capacity_bytes") or 42949672960)
disks.append(
{
"key": str(2000 + idx),
"value": {
"label": f"Hard disk {idx + 1}",
"capacity": capacity,
"type": "SCSI",
},
}
)
nics = None
if isinstance(nics_spec, list) and nics_spec:
nics = []
for idx, nic in enumerate(nics_spec):
value = nic.get("value") or nic
backing = value.get("backing") or {}
network = str(backing.get("network") or (networks[0] if networks else "network-41"))
nics.append(
{
"key": str(4000 + idx),
"value": {
"label": f"Network adapter {idx + 1}",
"mac": value.get("mac_address") or f"00:50:56:01:00:{idx:02x}",
"state": "NOT_CONNECTED",
"type": value.get("type") or "VMXNET3",
"backing": {"type": "STANDARD_PORTGROUP", "network": network},
},
}
)
if network not in networks:
networks.append(network)
moid, _task = await vm_ops.create_vm(
database,
name=name,
folder=str(folder),
host=str(host),
datastore=str(datastore),
resource_pool=str(pool),
guest_os=str(guest),
cpu_count=cpu,
memory_size_mib=memory,
networks=[str(n) for n in networks],
disks=disks,
nics=nics,
)
return moid
@router.delete("/api/vcenter/vm/{vm}")
async def delete_vm(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Delete")),
) -> Response:
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
if obj.props.get("power_state") == "POWERED_ON":
raise invalid_argument("VM must be powered off before delete")
await inventory.delete_object(database, vm)
return Response(status_code=204)
@router.get("/api/vcenter/vm/{vm}/power")
async def get_vm_power(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, str]:
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
state = str(obj.props.get("power_state") or "POWERED_OFF")
return {"state": state}
@router.post("/api/vcenter/vm/{vm}/power")
async def power_vm(
vm: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.PowerOn")),
) -> dict[str, str]:
task_id = await vm_ops.set_power(database, vm, action.lower())
return {"task": task_id}
@router.get("/api/vcenter/host")
async def list_hosts(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="HostSystem")
return [mappers.host_summary(obj) for obj in objects]
@router.get("/api/vcenter/host/{host}")
async def get_host(
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")
return mappers.host_info(obj)
@router.get("/api/vcenter/datastore")
async def list_datastores(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="Datastore")
return [mappers.datastore_summary(obj) for obj in objects]
@router.get("/api/vcenter/datastore/{datastore}")
async def get_datastore(
datastore: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.require_object(
database, datastore, type_name="Datastore", resource="datastore"
)
props = obj.props
return {
"name": obj.name,
"type": props.get("type", "VMFS"),
"accessible": props.get("accessible", True),
"free_space": props.get("free_space", 0),
"capacity": props.get("capacity", 0),
"multiple_host_access": props.get("multiple_host_access", True),
}
@router.get("/api/vcenter/network")
async def list_networks(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
networks = await inventory.list_objects(database, type_name="Network")
dvpg = await inventory.list_objects(database, type_name="DistributedVirtualPortgroup")
return [mappers.network_summary(obj) for obj in (*networks, *dvpg)]
@router.get("/api/vcenter/datacenter")
async def list_datacenters(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="Datacenter")
return [mappers.datacenter_summary(obj) for obj in objects]
@router.get("/api/vcenter/cluster")
async def list_clusters(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="ClusterComputeResource")
return [mappers.cluster_summary(obj) for obj in objects]
@router.get("/api/vcenter/folder")
async def list_folders(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="Folder")
return [mappers.folder_summary(obj) for obj in objects]
@router.get("/api/vcenter/resource-pool")
async def list_resource_pools(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="ResourcePool")
return [mappers.resource_pool_summary(obj) for obj in objects]
@router.get("/api/vcenter/vm/{vm}/guest/identity")
async def vm_guest_identity(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
return {
"name": obj.name,
"family": "LINUX",
"full_name": {"default_message": obj.props.get("guest_OS", "OTHER")},
"ip_address": "10.0.0." + obj.moid.split("-")[-1],
"host_name": obj.name,
}
async def _credentials_from_request(
request: Request,
credentials: HTTPBasicCredentials | None,
) -> tuple[str, str]:
if credentials is not None:
return credentials.username, credentials.password
# Some clients post JSON credentials (lab convenience).
if request.headers.get("content-type", "").startswith("application/json"):
try:
payload = await request.json()
except Exception:
payload = {}
if isinstance(payload, dict) and payload.get("user_name") and payload.get("password"):
return str(payload["user_name"]), str(payload["password"])
raise unauthenticated("Basic authentication required")