e8b08526d1
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.
441 lines
16 KiB
Python
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")
|