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:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
"""vSphere Automation REST routers.
Package init stays free of FastAPI so probes (e.g. pulumi-tests) can import
``app.vsphere.rest.coverage`` / matrix helpers without the full app stack.
``vsphere_rest_router`` is assembled lazily on first access.
"""
from __future__ import annotations
from typing import Any
__all__ = ["vsphere_rest_router"]
def __getattr__(name: str) -> Any:
if name == "vsphere_rest_router":
from fastapi import APIRouter
from app.vsphere.rest.appliance_ext import router as appliance_ext_router
from app.vsphere.rest.content_rest import router as content_router
from app.vsphere.rest.inventory_ext import router as inventory_ext_router
from app.vsphere.rest.legacy import router as legacy_router
from app.vsphere.rest.nfc_rest import router as nfc_router
from app.vsphere.rest.platform_rest import router as platform_router
from app.vsphere.rest.router import router as core_router
from app.vsphere.rest.stub_surface import router as stub_surface_router
from app.vsphere.rest.tagging_rest import router as tagging_router
from app.vsphere.rest.tasks import router as tasks_router
from app.vsphere.rest.vm_ext import router as vm_ext_router
vsphere_rest_router = APIRouter()
vsphere_rest_router.include_router(core_router)
vsphere_rest_router.include_router(tasks_router)
vsphere_rest_router.include_router(vm_ext_router)
vsphere_rest_router.include_router(inventory_ext_router)
vsphere_rest_router.include_router(tagging_router)
vsphere_rest_router.include_router(content_router)
vsphere_rest_router.include_router(appliance_ext_router)
vsphere_rest_router.include_router(platform_router)
vsphere_rest_router.include_router(nfc_router)
vsphere_rest_router.include_router(legacy_router)
# Broadcom universe per-path stubs — must stay last so deep handlers win.
vsphere_rest_router.include_router(stub_surface_router)
globals()["vsphere_rest_router"] = vsphere_rest_router
return vsphere_rest_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+123
View File
@@ -0,0 +1,123 @@
"""Appliance health / networking / timesync — mutable lab state."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import appliance
from app.vsphere.errors import invalid_argument
from app.vsphere.security.authz import require_admin, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Appliance"])
@router.get("/api/appliance/health/system")
async def health_system(
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/appliance/health/system")
return payload if isinstance(payload, dict) else {"status": "unknown", "messages": []}
@router.get("/api/appliance/networking")
async def networking(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await appliance.get_networking(database)
@router.get("/api/appliance/networking/dns/hostname")
async def get_dns_hostname(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, str]:
return {"name": await appliance.get_hostname(database)}
@router.put("/api/appliance/networking/dns/hostname")
@router.post("/api/appliance/networking/dns/hostname")
async def set_dns_hostname(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, str]:
name = str(body.get("name") or body.get("hostname") or "").strip()
if not name:
raise invalid_argument("name is required")
hostname = await appliance.set_hostname(database, name)
return {"name": hostname}
@router.get("/api/appliance/networking/dns/servers")
async def get_dns_servers(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
networking_state = await appliance.get_networking(database)
dns = networking_state.get("dns") or {}
return {"mode": dns.get("mode"), "servers": list(dns.get("servers") or [])}
@router.put("/api/appliance/networking/dns/servers")
@router.post("/api/appliance/networking/dns/servers")
async def set_dns_servers(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
mode = body.get("mode")
servers = body.get("servers")
if servers is None and body.get("server"):
servers = [body["server"]]
return await appliance.set_dns_servers(
database,
mode=str(mode) if mode is not None else None,
servers=[str(s) for s in servers] if isinstance(servers, list) else None,
)
@router.get("/api/appliance/networking/dns/domains")
async def get_dns_domains(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
networking_state = await appliance.get_networking(database)
dns = networking_state.get("dns") or {}
return list(dns.get("domains") or [])
@router.put("/api/appliance/networking/dns/domains")
@router.post("/api/appliance/networking/dns/domains")
async def set_dns_domains(
body: dict[str, Any] | list[Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> list[str]:
if isinstance(body, list):
domains = [str(d) for d in body]
else:
domains = [str(d) for d in (body.get("domains") or body.get("domain") or [])]
if isinstance(body.get("domain"), str):
domains = [str(body["domain"])]
if not domains:
from app.vsphere.errors import invalid_argument
raise invalid_argument("domains is required")
return await appliance.set_dns_domains(database, domains)
@router.get("/api/appliance/timesync")
async def timesync(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await appliance.get_timesync(database)
+191
View File
@@ -0,0 +1,191 @@
"""Content library, OVF deploy, storage policies, privileges."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import api_state, content
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Content"])
@router.get("/api/content/library")
async def list_libraries(
database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[str]:
return [item["id"] for item in await content.list_libraries(database)]
@router.post("/api/content/local-library")
async def create_library(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.CreateLocalLibrary")),
) -> str:
from app.vsphere.errors import invalid_argument
spec = body.get("create_spec") or body
name = spec.get("name") if isinstance(spec, dict) else None
if not name:
raise invalid_argument("create_spec.name is required")
return await content.create_library(
database, name=str(name), description=str(spec.get("description") or "")
)
@router.get("/api/content/library/item")
async def list_items(
library_id: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
return [item["id"] for item in await content.list_library_items(database, library_id)]
@router.post("/api/content/library/item")
async def create_item(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> str:
from app.vsphere.errors import invalid_argument
spec = body.get("create_spec") or body
if not isinstance(spec, dict):
raise invalid_argument("create_spec is required")
library_id = spec.get("library_id")
name = spec.get("name")
if not library_id:
raise invalid_argument("create_spec.library_id is required")
if not name:
raise invalid_argument("create_spec.name is required")
return await content.create_library_item(
database,
library_id=str(library_id),
name=str(name),
item_type=str(spec.get("type") or "ovf"),
description=str(spec.get("description") or ""),
)
@router.post("/api/vcenter/ovf/library-item/{item_id}")
async def deploy_ovf(
item_id: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.DeployTemplate")),
) -> dict[str, Any]:
target = body.get("target") or {}
deployment = body.get("deployment_spec") or body
moid, task_id = await content.deploy_ovf_from_library(
database,
item_id=item_id,
name=str(deployment.get("name") or f"ovf-{item_id[-6:]}"),
folder=str(target.get("folder") or "group-v23"),
host=str(target.get("host") or "host-11"),
datastore=str(target.get("datastore") or "datastore-31"),
)
return {"resource_id": {"id": moid, "type": "VirtualMachine"}, "task": task_id}
@router.post("/api/content/library/item/update-session")
async def create_update_session(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> str:
create_spec = body.get("create_spec") or body
library_item_id = str(create_spec.get("library_item_id") or create_spec.get("item_id") or "")
if not library_item_id:
from app.vsphere.errors import invalid_argument
raise invalid_argument("create_spec.library_item_id is required")
return await content.create_update_session(database, library_item_id=library_item_id)
@router.get("/api/content/library/item/update-session/{session_id}")
async def get_update_session(
session_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await content.get_update_session(database, session_id)
@router.post("/api/content/library/item/update-session/{session_id}/file")
async def add_update_session_file(
session_id: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> dict[str, Any]:
file_spec = body.get("file_spec") or body
return await content.add_update_session_file(
database,
session_id,
name=str(file_spec.get("name") or "upload.bin"),
source_type=str(file_spec.get("source_type") or "PUSH"),
size=int(file_spec.get("size") or 0),
content=str(file_spec.get("content") or ""),
)
@router.post("/api/content/library/item/update-session/{session_id}")
async def complete_update_session(
session_id: str,
action: str = Query("complete"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> None:
del action
await content.complete_update_session(database, session_id)
@router.post("/api/content/library/item/download-session")
async def create_download_session(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> str:
create_spec = body.get("create_spec") or body
library_item_id = str(create_spec.get("library_item_id") or create_spec.get("item_id") or "")
if not library_item_id:
from app.vsphere.errors import invalid_argument
raise invalid_argument("create_spec.library_item_id is required")
return await content.create_download_session(database, library_item_id=library_item_id)
@router.get("/api/content/library/item/download-session/{session_id}")
async def get_download_session(
session_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await content.get_download_session(database, session_id)
@router.get("/api/content/library/item/download-session/{session_id}/file")
async def list_download_session_files(
session_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
return await content.list_download_session_files(database, session_id)
@router.get("/api/vcenter/storage/policies")
async def storage_policies(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
payload = await api_state.get_payload(database, "GET", "/api/vcenter/storage/policies")
if isinstance(payload, list):
return payload
return []
+222
View File
@@ -0,0 +1,222 @@
"""Implemented REST path registry for UI catalog and surface probes.
CORE_* entries are deep lab handlers. Broadcom Automation API routes from
``universe.json`` (generated from the public operations index) fill the rest as
thin stubs — each ``(verb, path)`` is registered separately in ``stub_surface``.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
ACTIVE_STATUSES = frozenset({"implemented", "stub"})
# verb + path template → status (deep handlers)
CORE_IMPLEMENTED: dict[tuple[str, str], str] = {
("POST", "/api/session"): "implemented",
("DELETE", "/api/session"): "implemented",
("GET", "/api/session"): "implemented",
("POST", "/rest/com/vmware/cis/session"): "implemented",
("GET", "/rest/com/vmware/cis/session"): "implemented",
("DELETE", "/rest/com/vmware/cis/session"): "implemented",
("GET", "/api/cis/tasks"): "implemented",
("GET", "/api/cis/tasks/{task}"): "implemented",
("GET", "/api/appliance/system/version"): "implemented",
("GET", "/api/appliance/health/system"): "implemented",
("GET", "/api/appliance/networking"): "implemented",
("GET", "/api/appliance/networking/dns/hostname"): "implemented",
("PUT", "/api/appliance/networking/dns/hostname"): "implemented",
("POST", "/api/appliance/networking/dns/hostname"): "implemented",
("GET", "/api/appliance/networking/dns/servers"): "implemented",
("PUT", "/api/appliance/networking/dns/servers"): "implemented",
("POST", "/api/appliance/networking/dns/servers"): "implemented",
("GET", "/api/appliance/networking/dns/domains"): "implemented",
("PUT", "/api/appliance/networking/dns/domains"): "implemented",
("POST", "/api/appliance/networking/dns/domains"): "implemented",
("GET", "/api/appliance/timesync"): "implemented",
("GET", "/api/vcenter/vm"): "implemented",
("POST", "/api/vcenter/vm"): "implemented",
("GET", "/api/vcenter/vm/{vm}"): "implemented",
("DELETE", "/api/vcenter/vm/{vm}"): "implemented",
("GET", "/api/vcenter/vm/{vm}/power"): "implemented",
("POST", "/api/vcenter/vm/{vm}/power"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/identity"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/local-filesystem"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/filesystem"): "implemented",
("PUT", "/api/vcenter/vm/{vm}/guest/filesystem"): "implemented",
("DELETE", "/api/vcenter/vm/{vm}/guest/filesystem"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/filesystem/files"): "implemented",
("GET", "/api/vcenter/vm/{vm}/tools"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/cpu"): "implemented",
("PATCH", "/api/vcenter/vm/{vm}/hardware/cpu"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/memory"): "implemented",
("PATCH", "/api/vcenter/vm/{vm}/hardware/memory"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/disk"): "implemented",
("POST", "/api/vcenter/vm/{vm}/hardware/disk"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/ethernet"): "implemented",
("POST", "/api/vcenter/vm/{vm}/hardware/ethernet"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/boot"): "implemented",
("GET", "/api/vcenter/vm/{vm}/snapshots"): "implemented",
("POST", "/api/vcenter/vm/{vm}/snapshots"): "implemented",
("DELETE", "/api/vcenter/vm/{vm}/snapshots/{snapshot}"): "implemented",
("POST", "/api/vcenter/vm/{vm}/snapshots/{snapshot}"): "implemented",
("POST", "/api/vcenter/vm/{vm}/clone"): "implemented",
("POST", "/api/vcenter/vm/{vm}/relocate"): "implemented",
("GET", "/api/vcenter/host"): "implemented",
("GET", "/api/vcenter/host/{host}"): "implemented",
("POST", "/api/vcenter/host/{host}/maintenance"): "implemented",
("GET", "/api/vcenter/datastore"): "implemented",
("GET", "/api/vcenter/datastore/{datastore}"): "implemented",
("GET", "/api/vcenter/datastore/{datastore}/files"): "implemented",
("POST", "/api/vcenter/datastore/{datastore}/files"): "implemented",
("GET", "/api/vcenter/network"): "implemented",
("GET", "/api/vcenter/datacenter"): "implemented",
("POST", "/api/vcenter/datacenter"): "implemented",
("DELETE", "/api/vcenter/datacenter/{datacenter}"): "implemented",
("GET", "/api/vcenter/cluster"): "implemented",
("POST", "/api/vcenter/cluster"): "implemented",
("DELETE", "/api/vcenter/cluster/{cluster}"): "implemented",
("GET", "/api/vcenter/folder"): "implemented",
("POST", "/api/vcenter/folder"): "implemented",
("POST", "/api/vcenter/folder/{folder}"): "implemented",
("DELETE", "/api/vcenter/folder/{folder}"): "implemented",
("GET", "/api/vcenter/resource-pool"): "implemented",
("POST", "/api/vcenter/resource-pool"): "implemented",
("DELETE", "/api/vcenter/resource-pool/{resource_pool}"): "implemented",
("GET", "/api/vcenter/network/dvs"): "implemented",
("POST", "/api/vcenter/network/dvs"): "implemented",
("POST", "/api/vcenter/network/dvpg"): "implemented",
("GET", "/api/cis/tagging/category"): "implemented",
("POST", "/api/cis/tagging/category"): "implemented",
("GET", "/api/cis/tagging/category/{category_id}"): "implemented",
("DELETE", "/api/cis/tagging/category/{category_id}"): "implemented",
("GET", "/api/cis/tagging/tag"): "implemented",
("POST", "/api/cis/tagging/tag"): "implemented",
("GET", "/api/cis/tagging/tag/{tag_id}"): "implemented",
("DELETE", "/api/cis/tagging/tag/{tag_id}"): "implemented",
("POST", "/api/cis/tagging/tag-association"): "implemented",
("GET", "/api/content/library"): "implemented",
("POST", "/api/content/local-library"): "implemented",
("GET", "/api/content/library/item"): "implemented",
("POST", "/api/content/library/item"): "implemented",
("POST", "/api/content/library/item/update-session"): "implemented",
("GET", "/api/content/library/item/update-session/{session_id}"): "implemented",
("POST", "/api/content/library/item/update-session/{session_id}"): "implemented",
("POST", "/api/content/library/item/update-session/{session_id}/file"): "implemented",
("POST", "/api/content/library/item/download-session"): "implemented",
("GET", "/api/content/library/item/download-session/{session_id}"): "implemented",
("GET", "/api/content/library/item/download-session/{session_id}/file"): "implemented",
("POST", "/api/vcenter/ovf/library-item/{item_id}"): "implemented",
("GET", "/api/vcenter/storage/policies"): "implemented",
("GET", "/api/vcenter/storage/policies/{policy}/vm"): "implemented",
("GET", "/api/vcenter/privilege"): "implemented",
("GET", "/api/vcenter/authorization/roles"): "implemented",
("GET", "/api/vcenter/authorization/permissions"): "implemented",
("POST", "/api/vcenter/authorization/permissions"): "implemented",
("DELETE", "/api/vcenter/authorization/permissions/{permission_id}"): "implemented",
("GET", "/api/vcenter/namespaces"): "implemented",
("GET", "/api/vcenter/namespace-management/nsx-tier0-gateway"): "implemented",
("GET", "/api/vcenter/namespace-management/networks"): "implemented",
("GET", "/api/vcenter/namespace-management/virtual-machine-classes"): "implemented",
("GET", "/api/vcenter/identity/providers"): "implemented",
("POST", "/api/vcenter/identity/providers"): "implemented",
("GET", "/api/vcenter/identity/providers/{provider}"): "implemented",
("PUT", "/api/vcenter/identity/providers/{provider}"): "implemented",
("PATCH", "/api/vcenter/identity/providers/{provider}"): "implemented",
("DELETE", "/api/vcenter/identity/providers/{provider}"): "implemented",
("GET", "/api/vcenter/certificate-management/vcenter/tls-csr"): "implemented",
("POST", "/api/vcenter/certificate-management/vcenter/tls-csr"): "implemented",
("GET", "/api/vcenter/certificate-management/vcenter/trusted-root-chains"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/customization"): "implemented",
("GET", "/api/vcenter/certificate-management/vcenter/tls"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/networking"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/power"): "implemented",
("POST", "/api/vcenter/vm/{vm}/guest/power"): "implemented",
("POST", "/api/vcenter/vm/{vm}/tools"): "implemented",
("POST", "/api/vcenter/vm/{vm}/console/tickets"): "implemented",
("POST", "/api/vcenter/vm/{vm}/guest/customization"): "implemented",
("POST", "/api/vcenter/vm/{vm}"): "implemented",
("GET", "/api/vcenter/host/{host}/storage/storage-device"): "implemented",
("GET", "/api/vcenter/host/{host}/networking"): "implemented",
("GET", "/api/vcenter/folder/{folder}/children"): "implemented",
("GET", "/api/vapi/metadata/metamodel/service"): "implemented",
("GET", "/api/vapi/metadata/authentication/component"): "implemented",
("GET", "/api/vcenter/activity-history"): "implemented",
("GET", "/rest/vcenter/vm"): "implemented",
("GET", "/rest/vcenter/vm/{vm}"): "implemented",
("POST", "/rest/vcenter/vm/{vm}/power"): "implemented",
("GET", "/rest/vcenter/host"): "implemented",
("GET", "/rest/vcenter/datastore"): "implemented",
("GET", "/rest/vcenter/network"): "implemented",
("GET", "/rest/vcenter/datacenter"): "implemented",
("GET", "/rest/vcenter/cluster"): "implemented",
("GET", "/rest/appliance/system/version"): "implemented",
}
@lru_cache(maxsize=1)
def _universe_routes() -> dict[tuple[str, str], str]:
path = Path(__file__).with_name("universe.json")
if not path.is_file():
return {}
payload = json.loads(path.read_text(encoding="utf-8"))
out: dict[tuple[str, str], str] = {}
for entry in payload.get("methods") or []:
verb = str(entry.get("verb", "")).upper()
route = str(entry.get("path", ""))
if verb and route:
out[(verb, route)] = "stub"
return out
def _merged() -> dict[tuple[str, str], str]:
merged = dict(_universe_routes())
merged.update(CORE_IMPLEMENTED)
return merged
IMPLEMENTED: dict[tuple[str, str], str] = _merged()
def reload_coverage() -> dict[tuple[str, str], str]:
"""Reload universe.json (tests / generator)."""
_universe_routes.cache_clear()
IMPLEMENTED.clear()
IMPLEMENTED.update(_merged())
from contextlib import suppress
with suppress(Exception):
from app.vsphere.contracts.matrix import _compiled_routes
_compiled_routes.cache_clear()
return IMPLEMENTED
def catalog_entries() -> list[dict[str, str]]:
return [
{"verb": verb, "path": path, "status": status}
for (verb, path), status in sorted(IMPLEMENTED.items())
]
def is_implemented(verb: str, path: str) -> bool:
return IMPLEMENTED.get((verb.upper(), path)) in ACTIVE_STATUSES
def universe_stats() -> dict[str, int | str]:
path = Path(__file__).with_name("universe.json")
if not path.is_file():
return {"broadcom_operations": 0, "unique_routes": 0}
payload = json.loads(path.read_text(encoding="utf-8"))
return {
"source_label": str(payload.get("source_label") or ""),
"broadcom_operations": int(payload.get("broadcom_operations") or 0),
"unique_routes": int(payload.get("unique_routes") or 0),
"registry_methods": len(IMPLEMENTED),
"core_methods": len(CORE_IMPLEMENTED),
"stub_methods": sum(1 for status in IMPLEMENTED.values() if status == "stub"),
}
+213
View File
@@ -0,0 +1,213 @@
"""Inventory CRUD, host maintenance, datastore files, DVS."""
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 content, inventory_ops
from app.vsphere.errors import invalid_argument
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Inventory Ext"])
def _require_name(body: dict[str, Any]) -> str:
name = body.get("name")
if not name:
raise invalid_argument("name is required")
return str(name)
@router.post("/api/vcenter/datacenter")
async def create_dc(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Datacenter.Create")),
) -> str:
return await inventory_ops.create_datacenter(
database, name=_require_name(body), folder=str(body.get("folder") or "group-d1")
)
@router.delete("/api/vcenter/datacenter/{datacenter}")
async def delete_dc(
datacenter: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Datacenter.Delete")),
) -> Response:
await inventory_ops.delete_managed(database, datacenter)
return Response(status_code=204)
@router.post("/api/vcenter/cluster")
async def create_cluster(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Cluster.Create")),
) -> str:
return await inventory_ops.create_cluster(
database,
name=_require_name(body),
folder=str(body.get("folder") or "group-h23"),
drs_enabled=bool((body.get("drs") or {}).get("enabled", True)),
ha_enabled=bool((body.get("ha") or {}).get("enabled", True)),
)
@router.delete("/api/vcenter/cluster/{cluster}")
async def delete_cluster(
cluster: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Cluster.Delete")),
) -> Response:
await inventory_ops.delete_managed(database, cluster)
return Response(status_code=204)
@router.post("/api/vcenter/folder")
async def create_folder(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Folder.Create")),
) -> str:
return await inventory_ops.create_folder(
database,
name=_require_name(body),
parent=str(body.get("parent") or body.get("folder") or "group-v23"),
folder_type=str(body.get("type") or "VIRTUAL_MACHINE"),
)
@router.post("/api/vcenter/folder/{folder}")
async def folder_action(
folder: str,
action: str = Query(...),
body: dict[str, Any] | None = None,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Folder.Rename")),
) -> Response:
body = body or {}
if action == "rename":
await inventory_ops.rename_object(database, folder, _require_name(body))
elif action == "move":
parent = body.get("parent")
if not parent:
raise invalid_argument("parent is required")
await inventory_ops.move_object(database, folder, str(parent))
else:
raise invalid_argument(f"unsupported action {action}")
return Response(status_code=204)
@router.delete("/api/vcenter/folder/{folder}")
async def delete_folder(
folder: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Folder.Delete")),
) -> Response:
await inventory_ops.delete_managed(database, folder)
return Response(status_code=204)
@router.post("/api/vcenter/resource-pool")
async def create_rp(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Resource.CreatePool")),
) -> str:
return await inventory_ops.create_resource_pool(
database, name=_require_name(body), parent=str(body.get("parent") or "resgroup-22")
)
@router.delete("/api/vcenter/resource-pool/{resource_pool}")
async def delete_rp(
resource_pool: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Resource.DeletePool")),
) -> Response:
await inventory_ops.delete_managed(database, resource_pool)
return Response(status_code=204)
@router.post("/api/vcenter/host/{host}/maintenance")
async def host_maintenance(
host: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Host.Config.Maintenance")),
) -> dict[str, Any]:
if action not in {"enter", "exit"}:
raise invalid_argument("action must be enter|exit")
return await inventory_ops.set_host_maintenance(database, host, enabled=(action == "enter"))
@router.get("/api/vcenter/datastore/{datastore}/files")
async def ds_files(
datastore: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
return await content.list_datastore_files(database, datastore)
@router.post("/api/vcenter/datastore/{datastore}/files")
async def ds_put_file(
datastore: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Datastore.FileManagement")),
) -> Response:
path = body.get("path")
if not path:
raise invalid_argument("path is required")
await content.put_datastore_file(
database,
datastore,
str(path),
size=int(body.get("size") or 0),
file_type=str(body.get("type") or "FILE"),
)
return Response(status_code=204)
@router.get("/api/vcenter/network/dvs")
async def list_dvs(
database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="VmwareDistributedVirtualSwitch")
return [{"dvs": o.moid, "name": o.name} for o in objects]
@router.post("/api/vcenter/network/dvs")
async def create_dvs(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Network.Assign")),
) -> str:
return await inventory_ops.create_dvs(
database, name=_require_name(body), folder=str(body.get("folder") or "group-n23")
)
@router.post("/api/vcenter/network/dvpg")
async def create_dvpg(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Network.Assign")),
) -> str:
dvs = body.get("dvs")
if not dvs:
raise invalid_argument("dvs is required")
return await inventory_ops.create_dvpg(
database,
name=_require_name(body),
dvs=str(dvs),
vlan_id=int(body.get("vlan_id") or 0),
)
+223
View File
@@ -0,0 +1,223 @@
"""Legacy `/rest/...` JSON wrappers mirroring Automation API `/api` paths."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import vm_ops
from app.vsphere.errors import invalid_argument
from app.vsphere.rest import router as core
from app.vsphere.rest import tagging_rest
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Legacy REST"])
def _value(payload: Any) -> dict[str, Any]:
return {"value": payload}
@router.get("/rest/vcenter/vm")
async def rest_list_vms(
database: Database = Depends(get_database),
session: 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),
limit: int | None = Query(default=None, ge=1, le=5000),
cursor: int = Query(default=0, ge=0),
) -> dict[str, Any]:
payload = await core.list_vms(
database=database,
_=session,
names=names,
power_states=power_states,
hosts=hosts,
folders=folders,
datacenters=None,
clusters=None,
resource_pools=None,
limit=limit,
cursor=cursor,
)
return _value(payload)
@router.get("/rest/vcenter/vm/{vm}")
async def rest_get_vm(
vm: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.get_vm(vm=vm, database=database, _=session))
@router.get("/rest/vcenter/host")
async def rest_list_hosts(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_hosts(database=database, _=session))
@router.get("/rest/vcenter/datastore")
async def rest_list_datastores(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_datastores(database=database, _=session))
@router.get("/rest/vcenter/network")
async def rest_list_networks(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_networks(database=database, _=session))
@router.get("/rest/vcenter/datacenter")
async def rest_list_datacenters(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_datacenters(database=database, _=session))
@router.get("/rest/vcenter/cluster")
async def rest_list_clusters(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_clusters(database=database, _=session))
@router.get("/rest/appliance/system/version")
async def rest_appliance_version(
request: Request,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.appliance_version(request=request, database=database))
@router.post("/rest/vcenter/vm/{vm}/power")
async def rest_power_vm(
vm: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.PowerOn")),
) -> dict[str, Any]:
task_id = await vm_ops.set_power(database, vm, action.lower())
return _value({"task": task_id})
# --- CIS tagging (govmomi / terraform-provider-vsphere / pulumi-vsphere) ---
@router.get("/rest/com/vmware/cis/tagging/category")
async def rest_list_categories(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await tagging_rest.list_categories(database=database, _=session))
@router.post("/rest/com/vmware/cis/tagging/category")
async def rest_category_action(
body: dict[str, Any] | None = None,
action: str | None = Query(default=None, alias="~action"),
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateCategory")),
) -> Any:
payload = body or {}
act = (action or "create").lower()
if act == "create":
return _value(
await tagging_rest.create_category(body=payload, database=database, _=session)
)
raise invalid_argument(f"unsupported ~action {act}")
@router.get("/rest/com/vmware/cis/tagging/category/{category_id}")
async def rest_get_category(
category_id: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(
await tagging_rest.get_category(category_id=category_id, database=database, _=session)
)
@router.delete("/rest/com/vmware/cis/tagging/category/{category_id}")
async def rest_delete_category(
category_id: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateCategory")),
) -> Response:
return await tagging_rest.delete_category(category_id=category_id, database=database, _=session)
@router.get("/rest/com/vmware/cis/tagging/tag")
async def rest_list_tags(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await tagging_rest.list_tags(database=database, _=session))
@router.post("/rest/com/vmware/cis/tagging/tag")
async def rest_tag_action(
body: dict[str, Any] | None = None,
action: str | None = Query(default=None, alias="~action"),
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateTag")),
) -> Any:
payload = body or {}
act = (action or "create").lower()
if act == "create":
return _value(await tagging_rest.create_tag(body=payload, database=database, _=session))
raise invalid_argument(f"unsupported ~action {act}")
@router.get("/rest/com/vmware/cis/tagging/tag/{tag_id}")
async def rest_get_tag(
tag_id: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await tagging_rest.get_tag(tag_id=tag_id, database=database, _=session))
@router.delete("/rest/com/vmware/cis/tagging/tag/{tag_id}")
async def rest_delete_tag(
tag_id: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateTag")),
) -> Response:
return await tagging_rest.delete_tag(tag_id=tag_id, database=database, _=session)
@router.post("/rest/com/vmware/cis/tagging/tag-association")
async def rest_tag_association(
body: dict[str, Any] | None = None,
action: str | None = Query(default=None, alias="~action"),
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.AttachTag")),
) -> Any:
"""govmomi/terraform use ``?~action=`` instead of JSON ``action``."""
payload = dict(body or {})
if action and "action" not in payload:
payload["action"] = action
result = await tagging_rest.tag_association(body=payload, database=database, _=session)
if isinstance(result, Response):
return result
return _value(result)
+122
View File
@@ -0,0 +1,122 @@
"""Map inventory rows to vSphere Automation REST JSON shapes."""
from __future__ import annotations
from typing import Any
from app.vsphere.inventory import ManagedObject
def vm_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"vm": obj.moid,
"name": obj.name,
"power_state": props.get("power_state", "POWERED_OFF"),
"cpu_count": props.get("cpu_count"),
"memory_size_MiB": props.get("memory_size_mib"),
}
def vm_info(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
cpu = dict(props.get("cpu") or {})
return {
"guest_OS": props.get("guest_OS"),
"name": obj.name,
"identity": props.get("identity"),
"power_state": props.get("power_state"),
"hardware": {
"version": props.get("hardware_version"),
"cpu": {
"count": props.get("cpu_count"),
"cores_per_socket": cpu.get("cores_per_socket"),
},
"memory": {"size_MiB": props.get("memory_size_mib")},
},
"nics": props.get("nics") or [],
"disks": props.get("disks") or [],
"boot": props.get("boot") or {},
}
def host_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"host": obj.moid,
"name": obj.name,
"connection_state": props.get("connection_state", "CONNECTED"),
"power_state": props.get("power_state", "POWERED_ON"),
}
def host_info(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"name": obj.name,
"connection_state": props.get("connection_state", "CONNECTED"),
"power_state": props.get("power_state", "POWERED_ON"),
"cpu": {
"count": props.get("cpu_cores", 1),
"mhz": props.get("cpu_mhz", 2000),
},
"memory": {"size_MiB": props.get("memory_size_mib", 8192)},
}
def datastore_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"datastore": obj.moid,
"name": obj.name,
"type": props.get("type", "VMFS"),
"free_space": props.get("free_space", 0),
"capacity": props.get("capacity", 0),
}
def network_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"network": obj.moid,
"name": obj.name,
"type": props.get("type", "STANDARD_PORTGROUP"),
}
def datacenter_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"datacenter": obj.moid,
"name": obj.name,
"datastore_folder": props.get("datastore_folder"),
"host_folder": props.get("host_folder"),
"network_folder": props.get("network_folder"),
"vm_folder": props.get("vm_folder"),
}
def cluster_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"cluster": obj.moid,
"name": obj.name,
"drs_enabled": props.get("drs_enabled", False),
"ha_enabled": props.get("ha_enabled", False),
}
def folder_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"folder": obj.moid,
"name": obj.name,
"type": props.get("folder_type", "VIRTUAL_MACHINE"),
}
def resource_pool_summary(obj: ManagedObject) -> dict[str, Any]:
return {
"resource_pool": obj.moid,
"name": obj.name,
}
+80
View File
@@ -0,0 +1,80 @@
"""HttpNfcLease-compatible upload endpoints for OVF/VMDK lab transfers."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import platform_surface
from app.vsphere.errors import not_found
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere NFC"])
@router.get("/nfc/{lease_id}")
async def nfc_lease_status(
lease_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
lease = await platform_surface.get_nfc_lease(database, lease_id)
if lease is None:
raise not_found(f"NFC lease {lease_id} not found")
return lease
@router.post("/nfc/{lease_id}/complete")
async def nfc_lease_complete(
lease_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.DeployTemplate")),
) -> dict[str, Any]:
lease = await platform_surface.complete_nfc_lease(database, lease_id)
if lease is None:
raise not_found(f"NFC lease {lease_id} not found")
return lease
@router.put("/nfc/{lease_id}/files/{filename:path}")
@router.post("/nfc/{lease_id}/files/{filename:path}")
async def nfc_upload_file(
lease_id: str,
filename: str,
request: Request,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.DeployTemplate")),
) -> JSONResponse:
body = await request.body()
lease = await platform_surface.upload_nfc_file(database, lease_id, filename, len(body))
if lease is None:
raise not_found(f"NFC lease {lease_id} not found")
return JSONResponse(
content={
"lease": lease_id,
"filename": filename,
"bytes": len(body),
"state": lease.get("state"),
"transferProgress": lease.get("transferProgress"),
},
status_code=200,
)
@router.get("/nfc/{lease_id}/files/{filename:path}")
async def nfc_download_file(
lease_id: str,
filename: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> Response:
lease = await platform_surface.get_nfc_lease(database, lease_id)
if lease is None:
raise not_found(f"NFC lease {lease_id} not found")
content = f"# lab nfc placeholder for {filename} on {lease_id}\n".encode()
return Response(content=content, media_type="application/octet-stream")
+531
View File
@@ -0,0 +1,531 @@
"""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")
return customization if isinstance(customization, dict) else {}
@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)
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")
return filesystems if isinstance(filesystems, dict) else {}
@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.get_object(database, host)
if obj is None or obj.type != "HostSystem":
raise not_found(f"Host {host} not found")
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.get_object(database, host)
if obj is None or obj.type != "HostSystem":
raise not_found(f"Host {host} not found")
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]]:
parent = await inventory.get_object(database, folder)
if parent is None:
raise not_found(f"Folder {folder} not found")
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 []
+450
View File
@@ -0,0 +1,450 @@
"""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")
+349
View File
@@ -0,0 +1,349 @@
"""DB-backed per-path routes for Broadcom Automation API stubs without deep handlers.
Each ``(verb, path)`` from the universe registry is registered with
``APIRouter.add_api_route`` (same idea as Proxmox ``register_contract_routes``),
instead of a single ``/api/{full_path:path}`` catch-all.
"""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere import inventory
from app.vsphere.domain import api_state, tagging
from app.vsphere.domain import content as content_domain
from app.vsphere.errors import not_found
from app.vsphere.rest.coverage import IMPLEMENTED
from app.vsphere.security.authz import require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere REST surface"])
_PARAM_RE = re.compile(r"\{([A-Za-z0-9_]+)\}")
def _extract_params(template: str, concrete: str) -> dict[str, str]:
pattern = "^" + _PARAM_RE.sub(r"([^/]+)", template) + "$"
match = re.match(pattern, concrete)
if not match:
return {}
names = _PARAM_RE.findall(template)
return {name: match.group(index + 1) for index, name in enumerate(names)}
async def _live_get(database: Database, template: str, concrete: str) -> Any | None:
"""Return inventory/platform-backed payloads when possible."""
params = _extract_params(template, concrete)
if template == "/api/cis/tagging/category":
return await tagging.list_categories(database)
if template == "/api/cis/tagging/tag":
return await tagging.list_tags(database)
if template == "/api/content/library":
return await content_domain.list_libraries(database)
if template == "/api/content/library/item":
libs = await content_domain.list_libraries(database)
items: list[dict[str, Any]] = []
for lib in libs:
items.extend(await content_domain.list_library_items(database, lib["id"]))
return items
if template == "/api/content/local-library":
libs = await content_domain.list_libraries(database)
return [lib for lib in libs if lib.get("type") == "LOCAL"]
vm = params.get("vm")
if vm and "/hardware/" in template:
obj = await inventory.get_object(database, vm)
if obj is None:
raise not_found(f"VM {vm} not found")
props = obj.props or {}
def _live_list(key: str) -> list[Any] | None:
value = props.get(key)
if isinstance(value, list) and value:
return list(value)
return None
# Prefer non-empty inventory props; otherwise fall through to vsphere_api_state.
if template.endswith("/hardware/cdrom"):
return _live_list("cdroms")
if template.endswith("/hardware/floppy"):
return _live_list("floppies")
if template.endswith("/hardware/serial"):
return _live_list("serials")
if template.endswith("/hardware/parallel"):
return _live_list("parallels")
if template.endswith("/hardware/adapter/scsi"):
return _live_list("scsi_adapters")
if template.endswith("/hardware/adapter/sata"):
return _live_list("sata_adapters")
if template.endswith("/hardware/adapter/nvme"):
return _live_list("nvme_adapters")
if template.endswith("/hardware/boot") and isinstance(props.get("boot"), dict):
return props["boot"]
if template.endswith("/hardware/boot/device"):
return _live_list("boot_devices")
if template.endswith("/hardware/disk"):
return _live_list("disks") or list(props.get("disks") or [])
if template.endswith("/hardware/ethernet"):
return _live_list("nics") or list(props.get("nics") or [])
if "/hardware/disk/" in template and template.endswith("}"):
disk_id = params.get("disk")
for disk in props.get("disks") or []:
if str(disk.get("key") or disk.get("disk")) == str(disk_id):
return disk
if "/hardware/ethernet/" in template and template.endswith("}"):
nic_id = params.get("nic")
for nic in props.get("nics") or []:
if str(nic.get("key") or nic.get("nic")) == str(nic_id):
return nic
if vm and "/guest/" in template:
obj = await inventory.get_object(database, vm)
if obj is None:
raise not_found(f"VM {vm} not found")
props = obj.props or {}
if template.endswith("/guest/local-filesystem"):
if "guest_filesystems" in props:
return props["guest_filesystems"]
return None
identity = props.get("identity") or {}
if identity or props.get("guest_OS") or props.get("guest_ip"):
return {
"name": identity.get("name") or obj.name,
"family": "LINUX"
if "WIN" not in str(props.get("guest_OS", "")).upper()
else "WINDOWS",
"full_name": {"name": props.get("guest_OS") or obj.name},
"host_name": identity.get("name") or obj.name,
"ip_address": props.get("guest_ip"),
}
return None
host = params.get("host")
if host and template.endswith("/networking"):
obj = await inventory.get_object(database, host)
if obj is None:
raise not_found(f"Host {host} not found")
networking = (obj.props or {}).get("networking")
return networking if networking is not None else None
if host and "storage-device" in template:
obj = await inventory.get_object(database, host)
if obj is None:
raise not_found(f"Host {host} not found")
devices = (obj.props or {}).get("storage_devices")
return devices if devices is not None else None
return None
async def _mutate_vm_hardware(
database: Database,
template: str,
concrete: str,
verb: str,
body: dict[str, Any],
) -> Any | None:
params = _extract_params(template, concrete)
vm = params.get("vm")
if not vm or "/hardware/" not in template:
return False
obj = await inventory.get_object(database, vm)
if obj is None:
raise not_found(f"VM {vm} not found")
props = dict(obj.props or {})
if verb == "POST" and template.endswith("/hardware/cdrom"):
items = list(props.get("cdroms") or [])
key = str(3000 + len(items))
items.append(
{"cdrom": key, "label": f"CD/DVD drive {len(items) + 1}", "state": "CONNECTED", **body}
)
props["cdroms"] = items
await inventory.upsert_object(
database,
moid=vm,
type_name=obj.type,
name=obj.name,
parent_moid=obj.parent_moid,
props=props,
)
return key
if verb in {"PUT", "PATCH"} and template.endswith("/hardware/boot"):
props["boot"] = {**(props.get("boot") or {}), **body}
await inventory.upsert_object(
database,
moid=vm,
type_name=obj.type,
name=obj.name,
parent_moid=obj.parent_moid,
props=props,
)
return None
if verb == "DELETE" and "/hardware/cdrom/" in template:
cdrom = params.get("cdrom")
props["cdroms"] = [
c for c in (props.get("cdroms") or []) if str(c.get("cdrom")) != str(cdrom)
]
await inventory.upsert_object(
database,
moid=vm,
type_name=obj.type,
name=obj.name,
parent_moid=obj.parent_moid,
props=props,
)
return None
return False # not handled specialized; fall through to api_state
async def _dispatch(
template: str,
verb: str,
request: Request,
database: Database,
) -> Response:
concrete = request.url.path
method = verb.upper()
raw_body: Any = {}
if method in {"POST", "PUT", "PATCH"}:
try:
raw_body = await request.json()
except Exception:
raw_body = {}
if not isinstance(raw_body, dict):
raw_body = {"value": raw_body}
if method == "GET":
live = await _live_get(database, template, concrete)
if live is not None and not api_state.is_empty_payload(live):
return JSONResponse(content=live, status_code=200)
stored = await api_state.get_payload_or_seed(database, "GET", template)
if stored is None or api_state.is_empty_payload(stored):
return JSONResponse(content={"path": template, "status": "NOT_SEEDED"}, status_code=404)
return JSONResponse(content=stored, status_code=200)
hw = await _mutate_vm_hardware(database, template, concrete, method, raw_body)
if hw is not False:
if isinstance(hw, str):
return JSONResponse(content=hw, status_code=201)
return Response(status_code=204)
if method in {"PUT", "PATCH"}:
existing = await api_state.get_payload(database, "GET", template)
if isinstance(existing, dict) and isinstance(raw_body, dict):
merged = {**existing, **raw_body}
elif isinstance(raw_body, dict) and raw_body:
merged = raw_body
elif existing is not None:
merged = existing
else:
merged = {}
await api_state.put_payload(database, "GET", template, merged)
return JSONResponse(content=merged, status_code=200)
if method == "DELETE":
# Soft-delete: restore seed_payload from DB so lab GETs never go empty/404.
await api_state.restore_seed_payload(database, "GET", template)
if template.endswith("}"):
parent = template.rsplit("/", 1)[0]
if parent:
await api_state.restore_seed_payload(database, "GET", parent)
return Response(status_code=204)
# POST create / action
action = request.query_params.get("action")
if action:
await api_state.put_payload(
database,
"GET",
template,
{
"last_action": action,
"accepted": True,
"path": template,
**({} if not raw_body else {"spec": raw_body}),
},
)
if action.endswith("Task") or "task" in action.lower():
from app.vsphere.domain import tasks as task_store
task_id = await task_store.create_task(
database,
description=f"{action} {template}",
service="com.vmware.vapi",
operation=action,
status="SUCCEEDED",
result={"path": template, "action": action},
)
return JSONResponse(content=task_id, status_code=200)
return JSONResponse(content={"status": "SUCCESS", "action": action}, status_code=200)
new_id = await api_state.new_id(template.rstrip("/").rsplit("/", 1)[-1].strip("{}") or "id")
collection_payload = await api_state.get_payload(database, "GET", template)
created = {"id": new_id, "name": raw_body.get("name") or new_id, **raw_body}
if isinstance(collection_payload, list):
collection_payload = [*collection_payload, created]
await api_state.put_payload(database, "GET", template, collection_payload)
else:
await api_state.put_payload(
database, "GET", f"{template}/{{{template.rsplit('/', 1)[-1]}}}", created
)
await api_state.put_payload(database, "GET", template, created)
return JSONResponse(content=new_id, status_code=201)
def _endpoint(
template: str,
verb: str,
) -> Callable[..., Awaitable[Response]]:
async def dispatch(
request: Request,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> Response:
return await _dispatch(template, verb, request, database)
dispatch.__name__ = f"vsphere_stub_{verb}_{template.replace('/', '_').strip('_')}"
dispatch.__qualname__ = dispatch.__name__
return dispatch
def register_stub_routes(
target: APIRouter | None = None,
*,
implemented: dict[tuple[str, str], str] | None = None,
) -> int:
"""Register one FastAPI route per stub ``(verb, path)`` from the coverage registry.
Deep ``CORE_IMPLEMENTED`` handlers are skipped so they keep winning on their
dedicated routers. Returns the number of routes added.
"""
api = target if target is not None else router
registry = implemented if implemented is not None else IMPLEMENTED
added = 0
for (verb, path), status in sorted(registry.items(), key=lambda item: (item[0][1], item[0][0])):
if status != "stub":
continue
api.add_api_route(
path,
_endpoint(path, verb),
methods=[verb],
name=f"vsphere-stub:{verb}:{path}",
include_in_schema=True,
openapi_extra={"x-vmware-implementation": "stub"},
)
added += 1
return added
register_stub_routes()
+140
View File
@@ -0,0 +1,140 @@
"""CIS tagging REST."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Response
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import tagging
from app.vsphere.errors import invalid_argument
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Tagging"])
@router.get("/api/cis/tagging/category")
async def list_categories(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
return await tagging.list_categories(database)
@router.post("/api/cis/tagging/category")
async def create_category(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateCategory")),
) -> str:
spec = body.get("create_spec") if isinstance(body.get("create_spec"), dict) else body
name = spec.get("name") if isinstance(spec, dict) else None
if not name:
raise invalid_argument("create_spec.name is required")
return await tagging.create_category(
database,
name=str(name),
description=str(spec.get("description") or ""),
cardinality=str(spec.get("cardinality") or "MULTIPLE"),
associable_types=list(spec.get("associable_types") or []),
)
@router.get("/api/cis/tagging/category/{category_id}")
async def get_category(
category_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await tagging.get_category(database, category_id)
@router.delete("/api/cis/tagging/category/{category_id}")
async def delete_category(
category_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateCategory")),
) -> Response:
await tagging.delete_category(database, category_id)
return Response(status_code=204)
@router.get("/api/cis/tagging/tag")
async def list_tags(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
return await tagging.list_tags(database)
@router.post("/api/cis/tagging/tag")
async def create_tag(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateTag")),
) -> str:
spec = body.get("create_spec") if isinstance(body.get("create_spec"), dict) else body
if not isinstance(spec, dict):
raise invalid_argument("create_spec is required")
category_id = spec.get("category_id")
name = spec.get("name")
if not category_id:
raise invalid_argument("create_spec.category_id is required")
if not name:
raise invalid_argument("create_spec.name is required")
return await tagging.create_tag(
database,
category_id=str(category_id),
name=str(name),
description=str(spec.get("description") or ""),
)
@router.get("/api/cis/tagging/tag/{tag_id}")
async def get_tag(
tag_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await tagging.get_tag(database, tag_id)
@router.delete("/api/cis/tagging/tag/{tag_id}")
async def delete_tag(
tag_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateTag")),
) -> Response:
await tagging.delete_tag(database, tag_id)
return Response(status_code=204)
@router.post("/api/cis/tagging/tag-association")
async def tag_association(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.AttachTag")),
) -> Any:
action = str(body.get("action") or "attach")
tag_id = body.get("tag_id")
obj = body.get("object_id") or body.get("object") or {}
object_type = str(obj.get("type") or body.get("type") or "VirtualMachine")
object_id = str(obj.get("id") or body.get("id") or "")
if action == "list-attached-tags":
if not object_id:
raise invalid_argument("object_id.id is required")
return await tagging.list_attached_tags(database, object_type, object_id)
if not tag_id:
raise invalid_argument("tag_id is required")
if not object_id:
raise invalid_argument("object_id.id is required")
if action == "attach":
await tagging.attach_tag(database, str(tag_id), object_type, object_id)
return Response(status_code=204)
if action == "detach":
await tagging.detach_tag(database, str(tag_id), object_type, object_id)
return Response(status_code=204)
raise invalid_argument(f"unsupported action {action}")
+47
View File
@@ -0,0 +1,47 @@
"""CIS tasks REST."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import tasks as task_store
from app.vsphere.errors import not_found
from app.vsphere.security.authz import require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Tasks"])
@router.get("/api/cis/tasks")
async def list_tasks(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
tasks = await task_store.list_tasks(database)
if tasks:
return tasks
# Fresh lab DB — ensure callers always see at least one completed task.
await task_store.create_task(
database,
description="Lab inventory seed",
service="com.vmware.vcenter",
operation="seed",
result={"status": "SUCCEEDED"},
)
return await task_store.list_tasks(database)
@router.get("/api/cis/tasks/{task}")
async def get_task(
task: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
payload = await task_store.get_task(database, task)
if payload is None:
raise not_found(f"Task {task} not found")
return payload
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
"""HTTP middleware: resolve registered Automation API surface (lab: no version 501).
Historically this returned HTTP 501 when the hot-swapped catalog major was below
a path's PATH_FLOOR. That blocked real lab data for Ansible/Terraform/apps.
Catalog majors still filter the UI browse list; runtime always serves registered
routes with deep handlers or DB-backed stubs.
"""
from __future__ import annotations
from collections.abc import Callable
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.vsphere.contracts.matrix import available_for_request
class VsphereVersionGateMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable) -> Response:
path = request.url.path
if not (path.startswith("/api/") or path.startswith("/rest/")):
return await call_next(request)
major = getattr(request.app.state, "vsphere_contract_major", 9)
# Touch resolver so unknown paths still fall through to FastAPI 404 /
# stub catch-all; registered paths are never version-blocked.
available_for_request(request.method, path, int(major))
return await call_next(request)
+227
View File
@@ -0,0 +1,227 @@
"""VM hardware, snapshots, clone, tools — REST extensions."""
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.domain import vm_ops
from app.vsphere.errors import invalid_argument
from app.vsphere.rest import mappers
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere VM Ext"])
@router.get("/api/vcenter/vm/{vm}/tools")
async def vm_tools(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
tools = obj.props.get("tools")
if isinstance(tools, dict) and tools:
out = dict(tools)
out["run_state"] = (
"RUNNING" if obj.props.get("power_state") == "POWERED_ON" else "NOT_RUNNING"
)
return out
return {}
@router.get("/api/vcenter/vm/{vm}/hardware")
async def vm_hardware(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
return mappers.vm_info(obj)["hardware"]
@router.get("/api/vcenter/vm/{vm}/hardware/cpu")
async def get_cpu(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
cpu = dict(obj.props.get("cpu") or {})
return {
"count": obj.props.get("cpu_count")
if obj.props.get("cpu_count") is not None
else cpu.get("count"),
"cores_per_socket": cpu.get("cores_per_socket"),
"hot_add_enabled": cpu.get("hot_add_enabled"),
}
@router.patch("/api/vcenter/vm/{vm}/hardware/cpu")
async def patch_cpu(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.CPUCount")),
) -> Response:
await vm_ops.update_hardware_cpu(database, vm, int(body.get("count") or 1))
return Response(status_code=204)
@router.get("/api/vcenter/vm/{vm}/hardware/memory")
async def get_memory(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
memory = dict(obj.props.get("memory") or {})
return {
"size_MiB": obj.props.get("memory_size_mib")
if obj.props.get("memory_size_mib") is not None
else memory.get("size_MiB"),
"hot_add_enabled": memory.get("hot_add_enabled"),
}
@router.patch("/api/vcenter/vm/{vm}/hardware/memory")
async def patch_memory(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.Memory")),
) -> Response:
await vm_ops.update_hardware_memory(
database, vm, int(body.get("size_MiB") or body.get("size_mib") or 1024)
)
return Response(status_code=204)
@router.get("/api/vcenter/vm/{vm}/hardware/disk")
async def list_disks(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[dict[str, Any]]:
obj = await vm_ops.require_vm(database, vm)
return list(obj.props.get("disks") or [])
@router.post("/api/vcenter/vm/{vm}/hardware/disk")
async def create_disk(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.AddNewDisk")),
) -> str:
capacity = int(
(body.get("new_vmdk") or {}).get("capacity") or body.get("capacity") or 10737418240
)
disk = await vm_ops.add_disk(database, vm, capacity)
return str(disk["key"])
@router.get("/api/vcenter/vm/{vm}/hardware/ethernet")
async def list_nics(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[dict[str, Any]]:
obj = await vm_ops.require_vm(database, vm)
return list(obj.props.get("nics") or [])
@router.post("/api/vcenter/vm/{vm}/hardware/ethernet")
async def create_nic(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.AddRemoveDevice")),
) -> str:
network = ((body.get("backing") or {}).get("network")) or body.get("network") or "network-41"
nic = await vm_ops.add_nic(database, vm, str(network))
return str(nic["key"])
@router.get("/api/vcenter/vm/{vm}/hardware/boot")
async def get_boot(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
boot = obj.props.get("boot")
if isinstance(boot, dict) and boot:
return boot
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/vm/{vm}/hardware/boot")
return payload if isinstance(payload, dict) else {}
@router.get("/api/vcenter/vm/{vm}/snapshots")
async def list_snapshots(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[dict[str, Any]]:
return await vm_ops.list_snapshots(database, vm)
@router.post("/api/vcenter/vm/{vm}/snapshots")
async def create_snapshot(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.State.CreateSnapshot")),
) -> dict[str, str]:
snap_id, task_id = await vm_ops.create_snapshot(
database,
vm,
name=str(body.get("name") or "snapshot"),
description=str(body.get("description") or ""),
memory=bool(body.get("memory")),
)
return {"snapshot": snap_id, "task": task_id}
@router.delete("/api/vcenter/vm/{vm}/snapshots/{snapshot}")
async def delete_snapshot(
vm: str,
snapshot: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.State.RemoveSnapshot")),
) -> dict[str, str]:
task_id = await vm_ops.delete_snapshot(database, vm, snapshot)
return {"task": task_id}
@router.post("/api/vcenter/vm/{vm}/snapshots/{snapshot}")
async def revert_snapshot(
vm: str,
snapshot: str,
action: str = Query("revert"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.State.RevertToSnapshot")),
) -> dict[str, str]:
if action != "revert":
raise invalid_argument(f"unsupported action {action}")
task_id = await vm_ops.revert_snapshot(database, vm, snapshot)
return {"task": task_id}
@router.post("/api/vcenter/vm/{vm}/clone")
async def clone_vm(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.Clone")),
) -> dict[str, str]:
name = str(body.get("name") or f"{vm}-clone")
folder = (body.get("placement") or {}).get("folder")
moid, task_id = await vm_ops.clone_vm(
database, source_vm=vm, name=name, folder=folder, power_on=bool(body.get("power_on"))
)
return {"vm": moid, "task": task_id}
@router.post("/api/vcenter/vm/{vm}/relocate")
async def relocate_vm(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Move")),
) -> dict[str, str]:
placement = body.get("placement") or body
task_id = await vm_ops.relocate_vm(
database, vm, host=placement.get("host"), datastore=placement.get("datastore")
)
return {"task": task_id}