"""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, not_found, 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.get_object(database, vm) if obj is None or obj.type != "VirtualMachine": raise not_found(f"VM {vm} not found") 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.get_object(database, vm) if obj is None or obj.type != "VirtualMachine": raise not_found(f"VM {vm} not found") 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.get_object(database, vm) if obj is None or obj.type != "VirtualMachine": raise not_found(f"VM {vm} not found") 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.get_object(database, host) if obj is None or obj.type != "HostSystem": raise not_found(f"Host {host} not found") 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.get_object(database, datastore) if obj is None or obj.type != "Datastore": raise not_found(f"Datastore {datastore} not found") 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.get_object(database, vm) if obj is None or obj.type != "VirtualMachine": raise not_found(f"VM {vm} not found") 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")