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:
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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 []
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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 []
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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)
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user