Expand lab seed tiers, OpenAPI PARAMS, and console DATA/Params UX.

This commit is contained in:
2026-07-18 08:46:39 +03:00
parent f8d3cbdd59
commit 63cc409424
71 changed files with 38380 additions and 796 deletions
+104 -21
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Response
from app.db.pool import Database
from app.dependencies import get_database
@@ -22,6 +22,17 @@ async def list_libraries(
return [item["id"] for item in await content.list_libraries(database)]
@router.get("/api/content/local-library")
async def list_local_libraries(
database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[str]:
return [
item["id"]
for item in await content.list_libraries(database)
if str(item.get("type") or "LOCAL").upper() == "LOCAL"
]
@router.post("/api/content/local-library")
async def create_library(
body: dict[str, Any],
@@ -39,6 +50,7 @@ async def create_library(
)
# Static /library/item* paths must win over /library/{library_id}.
@router.get("/api/content/library/item")
async def list_items(
library_id: str = Query(...),
@@ -74,26 +86,6 @@ async def create_item(
)
@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],
@@ -180,6 +172,97 @@ async def list_download_session_files(
return await content.list_download_session_files(database, session_id)
@router.get("/api/content/library/item/{library_item_id}")
async def get_item(
library_item_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await content.get_library_item(database, library_item_id)
@router.delete("/api/content/library/item/{library_item_id}")
async def delete_item(
library_item_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> Response:
await content.delete_library_item(database, library_item_id)
return Response(status_code=204)
@router.get("/api/content/library/{library_id}")
async def get_library(
library_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await content.get_library(database, library_id)
@router.get("/api/content/local-library/{library_id}")
async def get_local_library(
library_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
info = await content.get_library(database, library_id)
if str(info.get("type") or "").upper() not in {"LOCAL", ""}:
from app.vsphere.errors import not_found
raise not_found(f"Local library {library_id} not found")
return info
@router.delete("/api/content/local-library/{library_id}")
async def delete_local_library(
library_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.CreateLocalLibrary")),
) -> Response:
await content.delete_library(database, library_id)
return Response(status_code=204)
@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
folder = (
target.get("folder_id")
or target.get("folder")
or deployment.get("folder")
or "group-v23"
)
host = target.get("host_id") or target.get("host") or "host-11"
datastore = (
target.get("datastore_id")
or target.get("datastore")
or deployment.get("datastore")
or "datastore-31"
)
resource_pool = (
target.get("resource_pool_id")
or target.get("resource_pool")
or deployment.get("resource_pool")
)
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(folder),
host=str(host),
datastore=str(datastore),
resource_pool=str(resource_pool) if resource_pool else None,
)
return {"resource_id": {"id": moid, "type": "VirtualMachine"}, "task": task_id}
@router.get("/api/vcenter/storage/policies")
async def storage_policies(
database: Database = Depends(get_database),
+7
View File
@@ -22,6 +22,7 @@ CORE_IMPLEMENTED: dict[tuple[str, str], str] = {
("GET", "/rest/com/vmware/cis/session"): "implemented",
("DELETE", "/rest/com/vmware/cis/session"): "implemented",
("GET", "/api/cis/tasks"): "implemented",
("POST", "/api/cis/tasks"): "implemented",
("GET", "/api/cis/tasks/{task}"): "implemented",
("GET", "/api/appliance/system/version"): "implemented",
("GET", "/api/appliance/health/system"): "implemented",
@@ -99,9 +100,15 @@ CORE_IMPLEMENTED: dict[tuple[str, str], str] = {
("DELETE", "/api/cis/tagging/tag/{tag_id}"): "implemented",
("POST", "/api/cis/tagging/tag-association"): "implemented",
("GET", "/api/content/library"): "implemented",
("GET", "/api/content/library/{library_id}"): "implemented",
("GET", "/api/content/local-library"): "implemented",
("POST", "/api/content/local-library"): "implemented",
("GET", "/api/content/local-library/{library_id}"): "implemented",
("DELETE", "/api/content/local-library/{library_id}"): "implemented",
("GET", "/api/content/library/item"): "implemented",
("POST", "/api/content/library/item"): "implemented",
("GET", "/api/content/library/item/{library_item_id}"): "implemented",
("DELETE", "/api/content/library/item/{library_item_id}"): "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",
+4 -3
View File
@@ -76,11 +76,12 @@ async def create_folder(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Folder.Create")),
) -> str:
spec = body.get("create_spec") if isinstance(body.get("create_spec"), dict) else body
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"),
name=_require_name(spec),
parent=str(spec.get("parent") or spec.get("folder") or "group-v23"),
folder_type=str(spec.get("type") or "VIRTUAL_MACHINE"),
)
+16 -3
View File
@@ -207,6 +207,7 @@ async def rest_delete_tag(
@router.post("/rest/com/vmware/cis/tagging/tag-association")
async def rest_tag_association(
request: Request,
body: dict[str, Any] | None = None,
action: str | None = Query(default=None, alias="~action"),
database: Database = Depends(get_database),
@@ -215,9 +216,21 @@ async def rest_tag_association(
"""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)
# Some clients send ``?~action=``; FastAPI alias can miss ``~`` — also read raw query.
resolved = (
action
or request.query_params.get("~action")
or request.query_params.get("action")
or payload.get("action")
)
if resolved:
payload["action"] = resolved
result = await tagging_rest.tag_association(
body=payload,
action=str(resolved) if resolved else None,
database=database,
_=session,
)
if isinstance(result, Response):
return result
return _value(result)
+87
View File
@@ -0,0 +1,87 @@
"""Flatten nested body_example values into PARAM drawer fields."""
from __future__ import annotations
from typing import Any
def body_fields_from_example(body_example: dict[str, Any] | None) -> list[dict[str, Any]]:
"""PARAM inputs derived from body_example, including nested scalar paths.
Nested objects/arrays become dotted paths (``placement.host``, ``cpu.count``,
``disks.0.new_vmdk.name``) so the Params drawer can edit leaves while the
request body keeps the full nested JSON.
"""
if not isinstance(body_example, dict) or not body_example:
return []
fields: list[dict[str, Any]] = []
def _leaf_type(value: Any) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int) and not isinstance(value, bool):
return "integer"
if isinstance(value, float):
return "number"
return "string"
def _walk(prefix: str, value: Any) -> None:
if isinstance(value, dict):
for key, child in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
_walk(path, child)
return
if isinstance(value, list):
for index, child in enumerate(value):
path = f"{prefix}.{index}" if prefix else str(index)
_walk(path, child)
return
fields.append(
{
"name": prefix,
"type": _leaf_type(value),
"description": prefix,
"optional": True,
"enum": [],
"example": value,
}
)
_walk("", body_example)
return fields
def set_by_path(root: dict[str, Any], path: str, value: Any) -> None:
"""Assign ``value`` at a dotted path, creating intermediate dicts/lists."""
parts = [part for part in str(path).split(".") if part]
if not parts:
return
cur: Any = root
for index, part in enumerate(parts[:-1]):
nxt = parts[index + 1]
want_list = nxt.isdigit()
if isinstance(cur, list):
idx = int(part)
while len(cur) <= idx:
cur.append([] if want_list else {})
if cur[idx] is None or not isinstance(cur[idx], (dict, list)):
cur[idx] = [] if want_list else {}
cur = cur[idx]
continue
if part not in cur or not isinstance(cur[part], (dict, list)):
cur[part] = [] if want_list else {}
cur = cur[part]
last = parts[-1]
if isinstance(cur, list):
idx = int(last)
while len(cur) <= idx:
cur.append(None)
cur[idx] = value
else:
cur[last] = value
__all__ = ["body_fields_from_example", "set_by_path"]
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -277,7 +277,14 @@ async def guest_customization_get(
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
customization = obj.props.get("customization")
return customization if isinstance(customization, dict) else {}
if isinstance(customization, dict) and customization:
return customization
# Seed / probe may wipe the field with POST {}; keep a non-empty lab view.
return {
"name": obj.name,
"status": "PENDING",
"spec": {"hostname": obj.name, "domain": "lab.local"},
}
@router.get("/api/vcenter/vm/{vm}/guest/networking")
@@ -352,6 +359,8 @@ async def guest_customization(
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.Rename")),
) -> dict[str, str]:
obj = await vm_ops.require_vm(database, vm)
if not isinstance(body, dict) or not body:
raise invalid_argument("customization spec is required")
props = dict(obj.props)
props["customization"] = body
await inventory.update_props(database, vm, props)
@@ -375,8 +384,13 @@ async def guest_local_filesystem(
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
filesystems = obj.props.get("guest_filesystems")
return filesystems if isinstance(filesystems, dict) else {}
if isinstance(filesystems, dict) and filesystems:
return filesystems
return {
"filesystems": {
"/": {"capacity": 42949672960, "free_space": 21474836480},
}
}
@router.get("/api/vcenter/vm/{vm}/guest/filesystem")
async def guest_filesystem_get(
+11 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Response
from fastapi import APIRouter, Depends, Query, Response
from app.db.pool import Database
from app.dependencies import get_database
@@ -115,15 +115,20 @@ async def delete_tag(
@router.post("/api/cis/tagging/tag-association")
async def tag_association(
body: dict[str, Any],
action: str | None = Query(None),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.AttachTag")),
) -> Any:
action = str(body.get("action") or "attach")
# Official Automation uses ?action=attach|detach|list-attached-tags.
# Legacy /rest uses ?~action=…; callers may also put action in the JSON body.
resolved = str(action or body.get("action") or "attach").strip().lower()
tag_id = body.get("tag_id")
obj = body.get("object_id") or body.get("object") or {}
if not isinstance(obj, dict):
obj = {}
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 resolved in {"list-attached-tags", "list-attached-tags-on-objects"}:
if not object_id:
raise invalid_argument("object_id.id is required")
return await tagging.list_attached_tags(database, object_type, object_id)
@@ -131,10 +136,10 @@ async def tag_association(
raise invalid_argument("tag_id is required")
if not object_id:
raise invalid_argument("object_id.id is required")
if action == "attach":
if resolved == "attach":
await tagging.attach_tag(database, str(tag_id), object_type, object_id)
return Response(status_code=204)
if action == "detach":
if resolved == "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}")
raise invalid_argument(f"unsupported action {resolved}")
+44 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from app.db.pool import Database
from app.dependencies import get_database
@@ -16,11 +16,7 @@ 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]]:
async def _ensure_seed_task(database: Database) -> list[dict[str, Any]]:
tasks = await task_store.list_tasks(database)
if tasks:
return tasks
@@ -35,6 +31,48 @@ async def list_tasks(
return await task_store.list_tasks(database)
@router.get("/api/cis/tasks")
async def list_tasks(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
# Lab convenience: return recent Cis Task Info objects (non-empty after seed).
return await _ensure_seed_task(database)
@router.post("/api/cis/tasks")
async def list_tasks_action(
body: dict[str, Any] | None = None,
action: str = Query("list"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any] | None:
"""Official Automation list: POST /api/cis/tasks?action=list → map id→info."""
if action == "list":
tasks = await _ensure_seed_task(database)
filter_spec = (body or {}).get("filter_spec") or (body or {})
wanted_tasks = set(filter_spec.get("tasks") or [])
wanted_services = set(filter_spec.get("services") or [])
wanted_status = set(filter_spec.get("status") or [])
out: dict[str, Any] = {}
for task in tasks:
tid = str(task.get("task") or "")
if wanted_tasks and tid not in wanted_tasks:
continue
if wanted_services and task.get("service") not in wanted_services:
continue
if wanted_status and task.get("status") not in wanted_status:
continue
out[tid] = task
return out
if action == "cancel":
# Cancel is accepted; task rows stay terminal when already finished.
return None
from app.vsphere.errors import invalid_argument
raise invalid_argument(f"unsupported action {action}")
@router.get("/api/cis/tasks/{task}")
async def get_task(
task: str,