Expand lab seed tiers, OpenAPI PARAMS, and console DATA/Params UX.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user