Expand lab seed tiers, OpenAPI PARAMS, and console DATA/Params UX.
This commit is contained in:
@@ -8,7 +8,9 @@ import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,3 +42,46 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class HeadAsGetMiddleware(BaseHTTPMiddleware):
|
||||
"""Serve HEAD for every GET route (deep + stub) with an empty body.
|
||||
|
||||
FastAPI ``add_api_route(methods=['GET'])`` and some router setups omit HEAD;
|
||||
contract matrix probes expect synthetic HEAD on each GET path.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestHandler) -> Response:
|
||||
if request.method != "HEAD":
|
||||
return await call_next(request)
|
||||
|
||||
# Replay as GET, then strip the body while preserving status/headers.
|
||||
request.scope["method"] = "GET"
|
||||
response = await call_next(request)
|
||||
|
||||
body = bytearray()
|
||||
async for chunk in response.body_iterator:
|
||||
if isinstance(chunk, str):
|
||||
body.extend(chunk.encode(response.charset or "utf-8"))
|
||||
else:
|
||||
body.extend(chunk)
|
||||
|
||||
headers = MutableHeaders(scope={"type": "http", "headers": []})
|
||||
for key, value in response.headers.items():
|
||||
if key.lower() in {"content-length", "content-type", "transfer-encoding"}:
|
||||
continue
|
||||
headers.append(key, value)
|
||||
headers["content-length"] = str(len(body))
|
||||
if response.media_type:
|
||||
headers["content-type"] = response.media_type
|
||||
|
||||
async def _empty_receive() -> Message:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
del _empty_receive
|
||||
return Response(
|
||||
content=b"",
|
||||
status_code=response.status_code,
|
||||
headers=headers,
|
||||
media_type=response.media_type,
|
||||
)
|
||||
|
||||
+3
-1
@@ -8,7 +8,7 @@ from typing import cast
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.errors import ApiError, api_error_handler, unhandled_exception_handler
|
||||
from app.api.middleware import RequestContextMiddleware
|
||||
from app.api.middleware import HeadAsGetMiddleware, RequestContextMiddleware
|
||||
from app.api.openapi import openapi_tag_metadata
|
||||
from app.api.registry import HandlerRegistry
|
||||
from app.config import Settings, get_settings
|
||||
@@ -103,6 +103,8 @@ def create_app(
|
||||
app.state.vsphere_contract_major = 9
|
||||
app.state.runtime_source_version = "8.0.2"
|
||||
app.add_middleware(RequestContextMiddleware, header_name=resolved.request_id_header)
|
||||
# Outermost-ish: translate HEAD→GET for all Automation routes (matrix probes).
|
||||
app.add_middleware(HeadAsGetMiddleware)
|
||||
from app.vsphere.rest.version_gate import VsphereVersionGateMiddleware
|
||||
|
||||
app.add_middleware(VsphereVersionGateMiddleware)
|
||||
|
||||
+109
-148
@@ -1,8 +1,19 @@
|
||||
"""Native vSphere API catalog (replaces Proxmox stub catalog in the console)."""
|
||||
"""Native vSphere API catalog for the Web UI console.
|
||||
|
||||
Parameter / request-body metadata comes from the official Automation OpenAPI
|
||||
(``app/vsphere/rest/param_index.json``, generated by
|
||||
``scripts/generate_vsphere_param_index.py``). Nested ``body_example`` values are
|
||||
flattened into dotted PARAM leaves (``placement.host``, ``cpu.count``, …).
|
||||
Path-parameter examples still use lab seed identifiers so Send works against
|
||||
the seeded inventory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.vsphere.contracts.matrix import (
|
||||
@@ -11,11 +22,13 @@ from app.vsphere.contracts.matrix import (
|
||||
is_implemented_for_major,
|
||||
load_bundle,
|
||||
)
|
||||
from app.vsphere.rest.param_fields import body_fields_from_example, set_by_path
|
||||
|
||||
_PATH_PARAM = re.compile(r"\{([^{}/]+)\}")
|
||||
_PARAM_INDEX_PATH = Path(__file__).resolve().parents[1] / "rest" / "param_index.json"
|
||||
|
||||
_PATH_EXAMPLES: dict[str, str] = {
|
||||
"vm": "vm-111",
|
||||
"vm": "vm-101",
|
||||
"host": "host-11",
|
||||
"datastore": "datastore-31",
|
||||
"task": "task-1",
|
||||
@@ -29,113 +42,17 @@ _PATH_EXAMPLES: dict[str, str] = {
|
||||
"resource_pool": "resgroup-22",
|
||||
"permission_id": "1",
|
||||
"policy": "policy-default",
|
||||
"library_id": "library-demo",
|
||||
}
|
||||
|
||||
# Common query/body fields for lab Params drawer (not a full OpenAPI schema).
|
||||
_QUERY_FIELDS: dict[tuple[str, str], list[dict[str, Any]]] = {
|
||||
("GET", "/api/vcenter/vm"): [
|
||||
{
|
||||
"name": "names",
|
||||
"type": "array",
|
||||
"optional": True,
|
||||
"example": "app-0011",
|
||||
"description": "Filter by VM name",
|
||||
},
|
||||
{
|
||||
"name": "hosts",
|
||||
"type": "array",
|
||||
"optional": True,
|
||||
"example": "host-11",
|
||||
"description": "Filter by host",
|
||||
},
|
||||
{
|
||||
"name": "power_states",
|
||||
"type": "array",
|
||||
"optional": True,
|
||||
"example": "POWERED_ON",
|
||||
"description": "Filter by power state",
|
||||
},
|
||||
],
|
||||
("POST", "/api/vcenter/vm/{vm}/power"): [
|
||||
{
|
||||
"name": "action",
|
||||
"type": "string",
|
||||
"optional": False,
|
||||
"example": "start",
|
||||
"description": "start|stop|reset|suspend",
|
||||
"enum": ["start", "stop", "reset", "suspend"],
|
||||
},
|
||||
],
|
||||
("POST", "/api/vcenter/folder/{folder}"): [
|
||||
{
|
||||
"name": "action",
|
||||
"type": "string",
|
||||
"optional": False,
|
||||
"example": "rename",
|
||||
"description": "rename|move",
|
||||
},
|
||||
],
|
||||
("POST", "/api/vcenter/host/{host}/maintenance"): [
|
||||
{
|
||||
"name": "action",
|
||||
"type": "string",
|
||||
"optional": False,
|
||||
"example": "enter",
|
||||
"description": "enter|exit",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
_BODY_FIELDS: dict[tuple[str, str], list[dict[str, Any]]] = {
|
||||
("POST", "/api/vcenter/vm"): [
|
||||
{"name": "name", "type": "string", "optional": False, "example": "lab-vm"},
|
||||
{
|
||||
"name": "placement",
|
||||
"type": "object",
|
||||
"optional": True,
|
||||
"example": '{"folder":"group-v23","host":"host-11","datastore":"datastore-31"}',
|
||||
},
|
||||
{"name": "cpu_count", "type": "integer", "optional": True, "example": "2"},
|
||||
{"name": "memory_size_MiB", "type": "integer", "optional": True, "example": "2048"},
|
||||
],
|
||||
("POST", "/api/vcenter/datacenter"): [
|
||||
{"name": "name", "type": "string", "optional": False, "example": "Datacenter-2"},
|
||||
{"name": "folder", "type": "string", "optional": True, "example": "group-d1"},
|
||||
],
|
||||
("POST", "/api/vcenter/cluster"): [
|
||||
{"name": "name", "type": "string", "optional": False, "example": "Cluster-2"},
|
||||
{"name": "folder", "type": "string", "optional": True, "example": "group-h23"},
|
||||
],
|
||||
("POST", "/api/vcenter/folder"): [
|
||||
{"name": "name", "type": "string", "optional": False, "example": "workloads"},
|
||||
{"name": "parent", "type": "string", "optional": True, "example": "group-v23"},
|
||||
{"name": "type", "type": "string", "optional": True, "example": "VIRTUAL_MACHINE"},
|
||||
],
|
||||
("POST", "/api/cis/tagging/category"): [
|
||||
{
|
||||
"name": "create_spec",
|
||||
"type": "object",
|
||||
"optional": False,
|
||||
"example": '{"name":"env","description":"lab","cardinality":"MULTIPLE","associable_types":[]}',
|
||||
},
|
||||
],
|
||||
("POST", "/api/cis/tagging/tag"): [
|
||||
{
|
||||
"name": "create_spec",
|
||||
"type": "object",
|
||||
"optional": False,
|
||||
"example": '{"name":"prod","category_id":"…"}',
|
||||
},
|
||||
],
|
||||
("POST", "/api/content/local-library"): [
|
||||
{
|
||||
"name": "create_spec",
|
||||
"type": "object",
|
||||
"optional": False,
|
||||
"example": '{"name":"Templates"}',
|
||||
},
|
||||
],
|
||||
}
|
||||
@lru_cache(maxsize=1)
|
||||
def _param_index() -> dict[str, Any]:
|
||||
if not _PARAM_INDEX_PATH.is_file():
|
||||
return {"methods": {}}
|
||||
payload = json.loads(_PARAM_INDEX_PATH.read_text(encoding="utf-8"))
|
||||
methods = payload.get("methods")
|
||||
return methods if isinstance(methods, dict) else {}
|
||||
|
||||
|
||||
def list_vsphere_majors(*, runtime_version: str | None) -> dict[str, Any]:
|
||||
@@ -230,23 +147,32 @@ def _path_fields(path: str) -> list[dict[str, Any]]:
|
||||
return fields
|
||||
|
||||
|
||||
def _body_example_from_fields(fields: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {}
|
||||
for field in fields:
|
||||
if field.get("optional"):
|
||||
def _normalize_index_fields(raw_fields: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(raw_fields, list):
|
||||
return []
|
||||
fields: list[dict[str, Any]] = []
|
||||
for item in raw_fields:
|
||||
if not isinstance(item, dict) or not item.get("name"):
|
||||
continue
|
||||
example = field.get("example")
|
||||
if isinstance(example, str) and example.startswith("{"):
|
||||
try:
|
||||
import json
|
||||
enum = item.get("enum") if isinstance(item.get("enum"), list) else []
|
||||
fields.append(
|
||||
_field(
|
||||
str(item["name"]),
|
||||
type_name=str(item.get("type") or "string"),
|
||||
optional=bool(item.get("optional", True)),
|
||||
example=item.get("example"),
|
||||
description=item.get("description") if isinstance(item.get("description"), str) else None,
|
||||
enum=[str(value) for value in enum],
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
body[field["name"]] = json.loads(example)
|
||||
continue
|
||||
except Exception:
|
||||
body[field["name"]] = example
|
||||
continue
|
||||
body[field["name"]] = example
|
||||
return body
|
||||
|
||||
def _lookup_param_entry(verb: str, path: str) -> dict[str, Any] | None:
|
||||
methods = _param_index()
|
||||
key = f"{verb.upper()} {path}"
|
||||
entry = methods.get(key)
|
||||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def vsphere_method_payload(
|
||||
@@ -259,31 +185,64 @@ def vsphere_method_payload(
|
||||
meta = VERSIONS.get(major) or VERSIONS[9]
|
||||
upper = verb.upper()
|
||||
path_fields = _path_fields(path)
|
||||
key = (upper, path)
|
||||
query_or_body = _QUERY_FIELDS.get(key, [])
|
||||
body_fields = list(_BODY_FIELDS.get(key, []))
|
||||
# Query-style action fields appear as body_fields in the Params UI (same editor).
|
||||
for item in query_or_body:
|
||||
body_fields.append(
|
||||
_field(
|
||||
str(item["name"]),
|
||||
type_name=str(item.get("type") or "string"),
|
||||
optional=bool(item.get("optional", True)),
|
||||
example=item.get("example"),
|
||||
description=item.get("description"),
|
||||
enum=list(item.get("enum") or []),
|
||||
)
|
||||
)
|
||||
# Generic POST with {path params} but no body schema → offer empty object note via name.
|
||||
if upper in {"POST", "PATCH", "PUT"} and not body_fields and "{" not in path:
|
||||
body_fields.append(
|
||||
_field(
|
||||
"name",
|
||||
optional=True,
|
||||
example="example",
|
||||
description="Primary name field when required by create APIs",
|
||||
)
|
||||
)
|
||||
entry = _lookup_param_entry(upper, path)
|
||||
|
||||
query_fields: list[dict[str, Any]] = []
|
||||
body_fields: list[dict[str, Any]] = []
|
||||
body_example: dict[str, Any] = {}
|
||||
|
||||
if entry is not None:
|
||||
# Prefer OpenAPI path examples when present, but keep lab seed IDs.
|
||||
indexed_path = _normalize_index_fields(entry.get("path_fields"))
|
||||
if indexed_path:
|
||||
by_name = {field["name"]: field for field in indexed_path}
|
||||
merged_path: list[dict[str, Any]] = []
|
||||
for field in path_fields:
|
||||
indexed = by_name.get(str(field["name"]))
|
||||
if indexed is None:
|
||||
merged_path.append(field)
|
||||
continue
|
||||
merged = dict(indexed)
|
||||
# Lab seed identifiers beat generic OpenAPI "example" strings.
|
||||
if field["name"] in _PATH_EXAMPLES:
|
||||
merged["example"] = _PATH_EXAMPLES[str(field["name"])]
|
||||
merged_path.append(merged)
|
||||
path_fields = merged_path
|
||||
query_fields = _normalize_index_fields(entry.get("query_fields"))
|
||||
body_fields = _normalize_index_fields(entry.get("body_fields"))
|
||||
raw_example = entry.get("body_example")
|
||||
if isinstance(raw_example, dict):
|
||||
body_example = raw_example
|
||||
|
||||
# Prefer leaf paths flattened from nested body_example (placement.host, …).
|
||||
nested_fields = body_fields_from_example(body_example)
|
||||
if nested_fields:
|
||||
body_fields = nested_fields
|
||||
elif not body_example and body_fields:
|
||||
# Build a nested example from dotted / JSON-string body fields.
|
||||
built: dict[str, Any] = {}
|
||||
for field in body_fields:
|
||||
if field.get("optional"):
|
||||
continue
|
||||
example = field.get("example")
|
||||
name = str(field["name"])
|
||||
if isinstance(example, str) and example[:1] in {"{", "["}:
|
||||
try:
|
||||
example = json.loads(example)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if "." in name:
|
||||
set_by_path(built, name, example)
|
||||
else:
|
||||
built[name] = example
|
||||
body_example = built
|
||||
nested_fields = body_fields_from_example(body_example)
|
||||
if nested_fields:
|
||||
body_fields = nested_fields
|
||||
|
||||
# Params drawer shows query + body together; keep query_fields distinct for URL build.
|
||||
params_fields = [*body_fields, *query_fields]
|
||||
|
||||
resolved = path
|
||||
for field in path_fields:
|
||||
resolved = resolved.replace(f"{{{field['name']}}}", str(field["example"]))
|
||||
@@ -295,10 +254,12 @@ def vsphere_method_payload(
|
||||
"description": f"{upper} {path}",
|
||||
"resolved_path": resolved,
|
||||
"path_fields": path_fields,
|
||||
"body_fields": body_fields,
|
||||
"query_fields": query_fields,
|
||||
"body_fields": params_fields,
|
||||
"indexed_fields": [],
|
||||
"body_example": _body_example_from_fields(body_fields),
|
||||
"body_example": body_example,
|
||||
"implemented": is_implemented_for_major(upper, path, major),
|
||||
"runtime_version": runtime_version or meta["version"],
|
||||
"source_version": meta["version"],
|
||||
"param_source": "openapi" if entry is not None else "none",
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ PATH_FLOOR: dict[tuple[str, str], int] = {
|
||||
# 7.0 U3 depth
|
||||
("GET", "/api/cis/tasks"): 7,
|
||||
("GET", "/api/cis/tasks/{task}"): 7,
|
||||
("POST", "/api/cis/tasks"): 7,
|
||||
("GET", "/api/vcenter/vm/{vm}/tools"): 7,
|
||||
("GET", "/api/vcenter/vm/{vm}/hardware"): 7,
|
||||
("GET", "/api/vcenter/vm/{vm}/hardware/cpu"): 7,
|
||||
@@ -95,9 +96,15 @@ PATH_FLOOR: dict[tuple[str, str], int] = {
|
||||
("POST", "/api/vcenter/network/dvs"): 8,
|
||||
("POST", "/api/vcenter/network/dvpg"): 8,
|
||||
("GET", "/api/content/library"): 8,
|
||||
("GET", "/api/content/library/{library_id}"): 8,
|
||||
("GET", "/api/content/local-library"): 8,
|
||||
("POST", "/api/content/local-library"): 8,
|
||||
("GET", "/api/content/local-library/{library_id}"): 8,
|
||||
("DELETE", "/api/content/local-library/{library_id}"): 8,
|
||||
("GET", "/api/content/library/item"): 8,
|
||||
("POST", "/api/content/library/item"): 8,
|
||||
("GET", "/api/content/library/item/{library_item_id}"): 8,
|
||||
("DELETE", "/api/content/library/item/{library_item_id}"): 8,
|
||||
("POST", "/api/vcenter/ovf/library-item/{item_id}"): 8,
|
||||
("GET", "/api/vcenter/storage/policies"): 8,
|
||||
("GET", "/api/vcenter/storage/policies/{policy}/vm"): 8,
|
||||
|
||||
+133
-20
@@ -65,19 +65,92 @@ async def create_library(
|
||||
return lib_id
|
||||
|
||||
|
||||
def _props(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return decoded if isinstance(decoded, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _library_info(row: Any) -> dict[str, Any]:
|
||||
props = _props(row["props"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"type": row["type"],
|
||||
"creation_time": props.get("creation_time"),
|
||||
"last_modified_time": props.get("last_modified_time"),
|
||||
"storage_backings": props.get("storage_backings")
|
||||
or [{"type": "DATASTORE", "datastore_id": "datastore-31"}],
|
||||
"state": props.get("state") or "ACTIVE",
|
||||
"version": str(props.get("version") or "1"),
|
||||
}
|
||||
|
||||
|
||||
def _library_item_info(row: Any) -> dict[str, Any]:
|
||||
props = _props(row["props"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"library_id": row["library_id"],
|
||||
"name": row["name"],
|
||||
"type": row["type"],
|
||||
"description": row["description"] or "",
|
||||
"content_version": str(props.get("content_version") or "1"),
|
||||
"creation_time": props.get("creation_time"),
|
||||
"last_modified_time": props.get("last_modified_time"),
|
||||
"size": int(props.get("size") or 0),
|
||||
"cached": bool(props.get("cached", True)),
|
||||
"security_compliance": bool(props.get("security_compliance", True)),
|
||||
}
|
||||
|
||||
|
||||
async def list_libraries(database: Database) -> list[dict[str, Any]]:
|
||||
pool = _pool(database)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("SELECT * FROM vsphere_libraries ORDER BY name")
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"type": row["type"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return [_library_info(row) for row in rows]
|
||||
|
||||
|
||||
async def get_library(database: Database, library_id: str) -> dict[str, Any]:
|
||||
pool = _pool(database)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT * FROM vsphere_libraries WHERE id = $1", library_id)
|
||||
if row is None:
|
||||
raise not_found(f"Library {library_id} not found")
|
||||
return _library_info(row)
|
||||
|
||||
|
||||
async def delete_library(database: Database, library_id: str) -> None:
|
||||
pool = _pool(database)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute("DELETE FROM vsphere_libraries WHERE id = $1", library_id)
|
||||
if result == "DELETE 0":
|
||||
raise not_found(f"Library {library_id} not found")
|
||||
|
||||
|
||||
async def get_library_item(database: Database, item_id: str) -> dict[str, Any]:
|
||||
pool = _pool(database)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT * FROM vsphere_library_items WHERE id = $1", item_id)
|
||||
if row is None:
|
||||
raise not_found(f"Library item {item_id} not found")
|
||||
return _library_item_info(row)
|
||||
|
||||
|
||||
async def delete_library_item(database: Database, item_id: str) -> None:
|
||||
pool = _pool(database)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute("DELETE FROM vsphere_library_items WHERE id = $1", item_id)
|
||||
if result == "DELETE 0":
|
||||
raise not_found(f"Library item {item_id} not found")
|
||||
|
||||
|
||||
async def create_library_item(
|
||||
@@ -138,16 +211,7 @@ async def list_library_items(database: Database, library_id: str) -> list[dict[s
|
||||
"SELECT * FROM vsphere_library_items WHERE library_id = $1 ORDER BY name",
|
||||
library_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"library_id": row["library_id"],
|
||||
"name": row["name"],
|
||||
"type": row["type"],
|
||||
"description": row["description"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return [_library_item_info(row) for row in rows]
|
||||
|
||||
|
||||
_LAB_SESSION_ID = "session-lab-1"
|
||||
@@ -411,6 +475,7 @@ async def deploy_ovf_from_library(
|
||||
folder: str = "group-v23",
|
||||
host: str = "host-11",
|
||||
datastore: str = "datastore-31",
|
||||
resource_pool: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
pool = _pool(database)
|
||||
async with pool.acquire() as conn:
|
||||
@@ -432,6 +497,7 @@ async def deploy_ovf_from_library(
|
||||
"hardware_version": "VMX_19",
|
||||
"host": host,
|
||||
"datastore": datastore,
|
||||
"resource_pool": resource_pool or "resgroup-22",
|
||||
"networks": ["network-41"],
|
||||
"identity": {"name": name},
|
||||
"deployed_from_library_item": item_id,
|
||||
@@ -496,15 +562,18 @@ async def put_datastore_file(
|
||||
)
|
||||
|
||||
|
||||
async def seed_platform_extras(database: Database) -> None:
|
||||
async def seed_platform_extras(database: Database, *, scale: int = 1) -> None:
|
||||
"""Idempotent demo libraries/tags/files/sessions when platform tables exist.
|
||||
|
||||
Always ensures stable lab IDs so probes and clients hit non-empty GETs:
|
||||
``lib-local-1``, ``item-ubuntu``, ``cat-lab-1``, ``tag-lab-1``, ``session-lab-1``, …
|
||||
``scale`` (1–4) adds proportional extra libraries/items/files for larger profiles.
|
||||
"""
|
||||
|
||||
from app.vsphere.domain import tasks as task_store
|
||||
|
||||
scale = max(1, min(int(scale or 1), 8))
|
||||
|
||||
# Ensure lab libraries/items even when older random-id rows already exist.
|
||||
lib = await create_library(
|
||||
database,
|
||||
@@ -543,6 +612,23 @@ async def seed_platform_extras(database: Database) -> None:
|
||||
item_id="item-golden",
|
||||
)
|
||||
|
||||
# Proportional extras for large/big tiers.
|
||||
for index in range(2, scale + 1):
|
||||
extra_lib = await create_library(
|
||||
database,
|
||||
name=f"Team Library {index}",
|
||||
description=f"Scaled lab library {index}",
|
||||
library_id=f"lib-local-{index}",
|
||||
)
|
||||
await create_library_item(
|
||||
database,
|
||||
library_id=extra_lib,
|
||||
name=f"template-{index:02d}",
|
||||
item_type="ovf",
|
||||
description=f"Scaled template {index}",
|
||||
item_id=f"item-lab-{index}",
|
||||
)
|
||||
|
||||
env = await tagging.create_category(
|
||||
database,
|
||||
name="Environment",
|
||||
@@ -592,6 +678,25 @@ async def seed_platform_extras(database: Database) -> None:
|
||||
await tagging.attach_tag(database, prod, "VirtualMachine", "vm-102")
|
||||
await tagging.attach_tag(database, "tag-lab-1", "VirtualMachine", "vm-101")
|
||||
|
||||
# Proportional categories/tags for larger tiers (extras_scale).
|
||||
for index in range(2, scale + 1):
|
||||
cat_id = f"cat-scale-{index}"
|
||||
tag_id = f"tag-scale-{index}"
|
||||
await tagging.create_category(
|
||||
database,
|
||||
name=f"Scale {index}",
|
||||
description=f"Scaled category {index}",
|
||||
associable_types=["VirtualMachine", "HostSystem"],
|
||||
category_id=cat_id,
|
||||
)
|
||||
await tagging.create_tag(
|
||||
database,
|
||||
category_id=cat_id,
|
||||
name=f"tier-{index}",
|
||||
tag_id=tag_id,
|
||||
)
|
||||
await tagging.attach_tag(database, tag_id, "VirtualMachine", "vm-101")
|
||||
|
||||
await put_datastore_file(
|
||||
database, "datastore-31", "[datastore1] ISO/ubuntu.iso", size=900000000
|
||||
)
|
||||
@@ -602,6 +707,14 @@ async def seed_platform_extras(database: Database) -> None:
|
||||
await put_datastore_file(
|
||||
database, "datastore-31", "[datastore1] web-01/web-01.vmdk", size=42949672960
|
||||
)
|
||||
for index in range(2, scale + 1):
|
||||
ds = f"datastore-{30 + min(index, 8)}"
|
||||
await put_datastore_file(
|
||||
database,
|
||||
ds,
|
||||
f"[ds-{index:02d}] ISO/lab-media-{index}.iso",
|
||||
size=50_000_000 * index,
|
||||
)
|
||||
|
||||
item_id = _LAB_ITEM_ID
|
||||
pool = _pool(database)
|
||||
|
||||
@@ -38,6 +38,9 @@ async def create_folder(
|
||||
async def create_datacenter(database: Database, *, name: str, folder: str = "group-d1") -> str:
|
||||
import secrets
|
||||
|
||||
parent_obj = await inventory.get_object(database, folder)
|
||||
if parent_obj is None:
|
||||
raise not_found(f"Parent {folder} not found")
|
||||
moid = f"datacenter-{secrets.randbelow(900) + 100}"
|
||||
host_folder = f"group-h{secrets.randbelow(90) + 10}"
|
||||
vm_folder = f"group-v{secrets.randbelow(90) + 10}"
|
||||
@@ -83,6 +86,9 @@ async def create_cluster(
|
||||
) -> str:
|
||||
import secrets
|
||||
|
||||
parent_obj = await inventory.get_object(database, folder)
|
||||
if parent_obj is None:
|
||||
raise not_found(f"Parent {folder} not found")
|
||||
moid = f"domain-c{secrets.randbelow(900) + 100}"
|
||||
rp = f"resgroup-{secrets.randbelow(900) + 100}"
|
||||
await inventory.upsert_object(
|
||||
@@ -162,6 +168,9 @@ async def delete_managed(database: Database, moid: str) -> None:
|
||||
obj = await inventory.get_object(database, moid)
|
||||
if obj is None:
|
||||
raise not_found(f"Object {moid} not found")
|
||||
# Protect the lab seed spine so surface/matrix probes cannot empty the inventory dump.
|
||||
if moid in _SEED_PROTECTED_MOIDS or _is_seed_host_or_named_vm(moid, obj):
|
||||
raise invalid_argument(f"Cannot delete protected seed object {moid}")
|
||||
children = [
|
||||
child for child in await inventory.list_objects(database) if child.parent_moid == moid
|
||||
]
|
||||
@@ -170,6 +179,44 @@ async def delete_managed(database: Database, moid: str) -> None:
|
||||
await inventory.delete_object(database, moid)
|
||||
|
||||
|
||||
# Stable MOIDs from profiles._topology / small named VMs (all seed sizes).
|
||||
_SEED_PROTECTED_MOIDS = frozenset(
|
||||
{
|
||||
"group-d1",
|
||||
"datacenter-21",
|
||||
"group-h23",
|
||||
"group-v23",
|
||||
"group-s23",
|
||||
"group-n23",
|
||||
"domain-c21",
|
||||
"resgroup-22",
|
||||
"group-v100",
|
||||
"group-v101",
|
||||
"group-v102",
|
||||
"network-41",
|
||||
"dvs-51",
|
||||
*(f"datastore-{30 + n}" for n in range(1, 9)),
|
||||
*(f"dvportgroup-{40 + n}" for n in range(2, 9)),
|
||||
*(f"group-v{103 + n}" for n in range(0, 8)),
|
||||
"vm-101",
|
||||
"vm-102",
|
||||
"vm-103",
|
||||
"vm-104",
|
||||
"vm-105",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_seed_host_or_named_vm(moid: str, obj: Any) -> bool:
|
||||
if obj.type == "HostSystem" and moid.startswith("host-"):
|
||||
# Seed hosts are host-11..host-N; probe hosts use other patterns if any.
|
||||
try:
|
||||
return 11 <= int(moid.split("-", 1)[1]) <= 40
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
async def set_host_maintenance(database: Database, host: str, enabled: bool) -> dict[str, Any]:
|
||||
obj = await inventory.get_object(database, host)
|
||||
if obj is None or obj.type != "HostSystem":
|
||||
|
||||
@@ -72,6 +72,15 @@ async def list_tasks(database: Database) -> list[dict[str, Any]]:
|
||||
return [_row(row) for row in rows]
|
||||
|
||||
|
||||
def _localizable(message: str, *, message_id: str = "com.vmware.cis.task.description") -> dict[str, Any]:
|
||||
return {
|
||||
"id": message_id,
|
||||
"default_message": message,
|
||||
"args": [],
|
||||
"localized": message,
|
||||
}
|
||||
|
||||
|
||||
def _row(row: Any) -> dict[str, Any]:
|
||||
result = row["result"]
|
||||
error = row["error"]
|
||||
@@ -80,20 +89,25 @@ def _row(row: Any) -> dict[str, Any]:
|
||||
if isinstance(error, str):
|
||||
error = json.loads(error)
|
||||
status = row["status"]
|
||||
state = {
|
||||
"PENDING": "PENDING",
|
||||
"RUNNING": "RUNNING",
|
||||
"SUCCEEDED": "SUCCEEDED",
|
||||
"FAILED": "FAILED",
|
||||
}.get(status, status)
|
||||
completed = 100 if status in {"SUCCEEDED", "FAILED"} else 50
|
||||
description_text = row["description"] or row["operation"] or "task"
|
||||
# Cis Task Info wire shape (Automation) plus lab-friendly aliases used by cookbooks.
|
||||
return {
|
||||
"task": row["id"],
|
||||
"description": row["description"],
|
||||
"description": _localizable(description_text),
|
||||
"status": status,
|
||||
"state": state,
|
||||
"state": status,
|
||||
"service": row["service"],
|
||||
"operation": row["operation"],
|
||||
"progress": 100 if status in {"SUCCEEDED", "FAILED"} else 50,
|
||||
"cancelable": False,
|
||||
"progress": {
|
||||
"total": 100,
|
||||
"completed": completed,
|
||||
"message": _localizable(
|
||||
f"{completed}%",
|
||||
message_id="com.vmware.cis.task.progress",
|
||||
),
|
||||
},
|
||||
"result": result,
|
||||
"error": error,
|
||||
"start_time": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
|
||||
@@ -394,19 +394,31 @@ async def revert_snapshot(database: Database, vm: str, snapshot: str) -> str:
|
||||
|
||||
async def update_hardware_cpu(database: Database, vm: str, count: int) -> None:
|
||||
obj = await require_vm(database, vm)
|
||||
if obj.props.get("power_state") == "POWERED_ON":
|
||||
raise invalid_argument("CPU count change requires powered-off VM in this simulator")
|
||||
cpu = dict(obj.props.get("cpu") or {})
|
||||
if obj.props.get("power_state") == "POWERED_ON" and not cpu.get("hot_add_enabled"):
|
||||
raise invalid_argument(
|
||||
"Virtual machine must be powered off to reconfigure CPU count "
|
||||
"when CPU hot-add is disabled"
|
||||
)
|
||||
props = dict(obj.props)
|
||||
props["cpu_count"] = count
|
||||
cpu["count"] = count
|
||||
props["cpu"] = cpu
|
||||
await inventory.update_props(database, vm, props)
|
||||
|
||||
|
||||
async def update_hardware_memory(database: Database, vm: str, size_mib: int) -> None:
|
||||
obj = await require_vm(database, vm)
|
||||
if obj.props.get("power_state") == "POWERED_ON":
|
||||
raise invalid_argument("Memory change requires powered-off VM in this simulator")
|
||||
memory = dict(obj.props.get("memory") or {})
|
||||
if obj.props.get("power_state") == "POWERED_ON" and not memory.get("hot_add_enabled"):
|
||||
raise invalid_argument(
|
||||
"Virtual machine must be powered off to reconfigure memory "
|
||||
"when memory hot-add is disabled"
|
||||
)
|
||||
props = dict(obj.props)
|
||||
props["memory_size_mib"] = size_mib
|
||||
memory["size_MiB"] = size_mib
|
||||
props["memory"] = memory
|
||||
await inventory.update_props(database, vm, props)
|
||||
|
||||
|
||||
|
||||
+152
-42
@@ -40,6 +40,7 @@ class VsphereSeedProfile:
|
||||
permissions: tuple[PermissionSpec, ...]
|
||||
host_count: int
|
||||
vm_count: int
|
||||
extras_scale: int = 1
|
||||
|
||||
|
||||
POWER_CYCLE = ("POWERED_ON", "POWERED_ON", "POWERED_ON", "POWERED_OFF", "SUSPENDED")
|
||||
@@ -63,6 +64,7 @@ def _topology(
|
||||
host_count: int,
|
||||
datastore_count: int = 4,
|
||||
network_count: int = 3,
|
||||
extra_vm_folders: int = 0,
|
||||
) -> list[ObjectSpec]:
|
||||
specs: list[ObjectSpec] = [
|
||||
ObjectSpec("group-d1", "Folder", "Datacenters", None, {"folder_type": "DATACENTER"}),
|
||||
@@ -111,6 +113,16 @@ def _topology(
|
||||
"group-v102", "Folder", "templates", "group-v23", {"folder_type": "VIRTUAL_MACHINE"}
|
||||
),
|
||||
]
|
||||
for index in range(extra_vm_folders):
|
||||
specs.append(
|
||||
ObjectSpec(
|
||||
f"group-v{103 + index}",
|
||||
"Folder",
|
||||
f"team-{index + 1:02d}",
|
||||
"group-v23",
|
||||
{"folder_type": "VIRTUAL_MACHINE"},
|
||||
)
|
||||
)
|
||||
for index in range(1, host_count + 1):
|
||||
moid = f"host-{10 + index}"
|
||||
specs.append(
|
||||
@@ -291,7 +303,13 @@ def _vm_device_props(*, name: str, power: str, index: int, nic_mac: str) -> dict
|
||||
}
|
||||
|
||||
|
||||
def _vm_spec(index: int, *, host_count: int) -> ObjectSpec:
|
||||
def _vm_spec(
|
||||
index: int,
|
||||
*,
|
||||
host_count: int,
|
||||
datastore_count: int = 4,
|
||||
folder_choices: tuple[str, ...] | None = None,
|
||||
) -> ObjectSpec:
|
||||
moid = f"vm-{100 + index}"
|
||||
role = ROLE_PREFIX[index % len(ROLE_PREFIX)]
|
||||
name = f"{role}-{index:04d}"
|
||||
@@ -299,9 +317,10 @@ def _vm_spec(index: int, *, host_count: int) -> ObjectSpec:
|
||||
host = f"host-{10 + (index % host_count) + 1}"
|
||||
cpus = 1 + (index % 8)
|
||||
memory = 1024 * (1 + (index % 16))
|
||||
folder = ("group-v100", "group-v101", "group-v23")[index % 3]
|
||||
folders = folder_choices or ("group-v100", "group-v101", "group-v23")
|
||||
folder = folders[index % len(folders)]
|
||||
guest = GUEST_OS[index % len(GUEST_OS)]
|
||||
ds_index = 1 + (index % 4)
|
||||
ds_index = 1 + (index % max(1, datastore_count))
|
||||
nic_tail = f"{(index % 250):02x}"
|
||||
devices = _vm_device_props(
|
||||
name=name,
|
||||
@@ -351,10 +370,35 @@ def lab_permissions() -> tuple[PermissionSpec, ...]:
|
||||
)
|
||||
|
||||
|
||||
def small_vsphere_profile() -> VsphereSeedProfile:
|
||||
"""Compact seed used by unit/integration tests (named VMs)."""
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProfileSize:
|
||||
"""Canonical lab sizes: hosts / VMs / datastores / networks / platform extras scale."""
|
||||
|
||||
name: str
|
||||
host_count: int
|
||||
vm_count: int
|
||||
datastore_count: int
|
||||
network_count: int
|
||||
extras_scale: int
|
||||
|
||||
|
||||
# Proportional inventory tiers shown in the Web UI DATA panel.
|
||||
PROFILE_SIZES: dict[str, ProfileSize] = {
|
||||
# Reset / unload target — cookbook-only inventory.
|
||||
"minimal": ProfileSize(
|
||||
"minimal", host_count=3, vm_count=5, datastore_count=1, network_count=1, extras_scale=1
|
||||
),
|
||||
"small": ProfileSize("small", host_count=3, vm_count=50, datastore_count=2, network_count=2, extras_scale=1),
|
||||
"large": ProfileSize(
|
||||
"large", host_count=10, vm_count=1000, datastore_count=4, network_count=4, extras_scale=2
|
||||
),
|
||||
"big": ProfileSize("big", host_count=20, vm_count=2000, datastore_count=8, network_count=8, extras_scale=4),
|
||||
}
|
||||
|
||||
|
||||
def _named_lab_vms() -> list[ObjectSpec]:
|
||||
"""Stable cookbook VMs (vm-101..vm-105) present in every profile."""
|
||||
|
||||
objects = _topology(host_count=3, datastore_count=2, network_count=2)
|
||||
named = (
|
||||
("web-01", "POWERED_ON", "host-11", 2, 4096),
|
||||
("web-02", "POWERED_ON", "host-12", 2, 4096),
|
||||
@@ -396,52 +440,97 @@ def small_vsphere_profile() -> VsphereSeedProfile:
|
||||
},
|
||||
)
|
||||
)
|
||||
return vms
|
||||
|
||||
|
||||
def _build_sized_profile(size: ProfileSize) -> VsphereSeedProfile:
|
||||
if size.host_count < 1 or size.vm_count < 1:
|
||||
raise ValueError("host_count and vm_count must be positive")
|
||||
# Folders scale with extras: minimal/small=0, large=2, big=6.
|
||||
extra_vm_folders = max(0, (size.extras_scale - 1) * 2)
|
||||
objects = _topology(
|
||||
host_count=size.host_count,
|
||||
datastore_count=size.datastore_count,
|
||||
network_count=size.network_count,
|
||||
extra_vm_folders=extra_vm_folders,
|
||||
)
|
||||
named = _named_lab_vms()
|
||||
# Minimal / single-datastore profiles still reference datastore-31.
|
||||
if size.datastore_count < 1:
|
||||
raise ValueError("datastore_count must be positive")
|
||||
folder_choices = (
|
||||
"group-v100",
|
||||
"group-v101",
|
||||
"group-v23",
|
||||
*(f"group-v{103 + i}" for i in range(extra_vm_folders)),
|
||||
)
|
||||
vms: list[ObjectSpec] = list(named)
|
||||
if size.vm_count > len(named):
|
||||
vms.extend(
|
||||
_vm_spec(
|
||||
index,
|
||||
host_count=size.host_count,
|
||||
datastore_count=size.datastore_count,
|
||||
folder_choices=folder_choices,
|
||||
)
|
||||
for index in range(len(named) + 1, size.vm_count + 1)
|
||||
)
|
||||
elif size.vm_count < len(named):
|
||||
vms = vms[: size.vm_count]
|
||||
return VsphereSeedProfile(
|
||||
name="small",
|
||||
name=size.name,
|
||||
objects=tuple(objects + vms),
|
||||
credentials=lab_credentials(),
|
||||
permissions=lab_permissions(),
|
||||
host_count=3,
|
||||
vm_count=5,
|
||||
host_count=size.host_count,
|
||||
vm_count=len(vms),
|
||||
extras_scale=size.extras_scale,
|
||||
)
|
||||
|
||||
|
||||
def minimal_vsphere_profile() -> VsphereSeedProfile:
|
||||
"""Reset target: 3 hosts · 5 cookbook VMs · 1 datastore · 1 network."""
|
||||
|
||||
return _build_sized_profile(PROFILE_SIZES["minimal"])
|
||||
|
||||
|
||||
def small_vsphere_profile() -> VsphereSeedProfile:
|
||||
"""Lab tier: 3 hosts · 50 VMs · 2 datastores · 2 networks."""
|
||||
|
||||
return _build_sized_profile(PROFILE_SIZES["small"])
|
||||
|
||||
|
||||
def large_vsphere_profile(*, host_count: int = 10, vm_count: int = 1000) -> VsphereSeedProfile:
|
||||
if host_count < 1 or vm_count < 1:
|
||||
raise ValueError("host_count and vm_count must be positive")
|
||||
objects = _topology(host_count=host_count, datastore_count=4, network_count=4)
|
||||
# Keep first five named VMs for cookbook / smoke compatibility.
|
||||
base = small_vsphere_profile()
|
||||
named_vms = [obj for obj in base.objects if obj.type == "VirtualMachine"]
|
||||
generated = [_vm_spec(index, host_count=host_count) for index in range(6, vm_count + 1)]
|
||||
# Ensure first 5 from small keep stable ids/names; replace generated slots 1-5.
|
||||
vms = list(named_vms)
|
||||
if vm_count > 5:
|
||||
vms.extend(generated)
|
||||
elif vm_count < 5:
|
||||
vms = vms[:vm_count]
|
||||
return VsphereSeedProfile(
|
||||
name="large",
|
||||
objects=tuple(objects + vms),
|
||||
credentials=lab_credentials(),
|
||||
permissions=lab_permissions(),
|
||||
host_count=host_count,
|
||||
vm_count=len(vms),
|
||||
"""Lab tier: 10 hosts · 1000 VMs (defaults); kwargs keep Makefile overrides."""
|
||||
|
||||
size = PROFILE_SIZES["large"]
|
||||
if host_count == size.host_count and vm_count == size.vm_count:
|
||||
return _build_sized_profile(size)
|
||||
# Custom scale: keep datastore/network proportion to hosts (≈0.4× hosts, min 2).
|
||||
datastore_count = max(2, round(host_count * 0.4))
|
||||
network_count = max(2, round(host_count * 0.4))
|
||||
return _build_sized_profile(
|
||||
ProfileSize(
|
||||
name="large",
|
||||
host_count=host_count,
|
||||
vm_count=vm_count,
|
||||
datastore_count=datastore_count,
|
||||
network_count=network_count,
|
||||
extras_scale=max(1, datastore_count // 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def big_vsphere_profile() -> VsphereSeedProfile:
|
||||
"""Lab tier: 20 hosts · 2000 VMs · 8 datastores · 8 networks."""
|
||||
|
||||
return _build_sized_profile(PROFILE_SIZES["big"])
|
||||
|
||||
|
||||
def demo_cluster_vsphere_profile() -> VsphereSeedProfile:
|
||||
"""Enterprise-shaped cluster: 20 hosts, 1000 VMs (aligned with Proxmox demo-cluster)."""
|
||||
"""Backward-compatible alias for ``big`` (UI / older docs used demo-cluster)."""
|
||||
|
||||
profile = large_vsphere_profile(host_count=20, vm_count=1000)
|
||||
return VsphereSeedProfile(
|
||||
name="demo-cluster",
|
||||
objects=profile.objects,
|
||||
credentials=profile.credentials,
|
||||
permissions=profile.permissions,
|
||||
host_count=profile.host_count,
|
||||
vm_count=profile.vm_count,
|
||||
)
|
||||
return big_vsphere_profile()
|
||||
|
||||
|
||||
def build_vsphere_profile(
|
||||
@@ -455,14 +544,35 @@ def build_vsphere_profile(
|
||||
large_hosts if large_hosts is not None else int(os.getenv("SEED_VSPHERE_LARGE_HOSTS", "10"))
|
||||
)
|
||||
vms = large_vms if large_vms is not None else int(os.getenv("SEED_VSPHERE_LARGE_VMS", "1000"))
|
||||
if profile_name.lower() in {"small", "minimal"}:
|
||||
if profile_name in {"minimal", "mini", "reset"}:
|
||||
return minimal_vsphere_profile()
|
||||
if profile_name in {"small"}:
|
||||
return small_vsphere_profile()
|
||||
if profile_name in {"demo-cluster", "demo", "enterprise"}:
|
||||
return demo_cluster_vsphere_profile()
|
||||
if profile_name in {"big", "demo-cluster", "demo", "enterprise"}:
|
||||
return big_vsphere_profile()
|
||||
if profile_name == "large":
|
||||
return large_vsphere_profile(host_count=hosts, vm_count=vms)
|
||||
raise ValueError(f"unknown vSphere seed profile: {profile_name}")
|
||||
|
||||
|
||||
def infer_profile_hint(*, hosts: int, vms: int, datastores: int = 0) -> str:
|
||||
"""Map live inventory counts back to a DATA-panel profile name."""
|
||||
|
||||
for size in PROFILE_SIZES.values():
|
||||
if hosts == size.host_count and vms == size.vm_count:
|
||||
if datastores and datastores != size.datastore_count:
|
||||
continue
|
||||
return size.name
|
||||
if hosts >= 15 and vms >= 1500:
|
||||
return "big"
|
||||
if hosts >= 8 and vms >= 500:
|
||||
return "large"
|
||||
if hosts <= 4 and vms <= 10:
|
||||
return "minimal"
|
||||
if hosts <= 4:
|
||||
return "small"
|
||||
return "custom"
|
||||
|
||||
|
||||
def props_json(props: dict[str, Any]) -> str:
|
||||
return json.dumps(props)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+15
-9
@@ -12,17 +12,18 @@ from app.vsphere.domain.api_state import seed_api_surface
|
||||
from app.vsphere.domain.appliance import seed_appliance_state
|
||||
from app.vsphere.domain.content import seed_platform_extras
|
||||
from app.vsphere.domain.platform_surface import seed_platform_surface
|
||||
from app.vsphere.profiles import VsphereSeedProfile, build_vsphere_profile, props_json
|
||||
from app.vsphere.profiles import VsphereSeedProfile, build_vsphere_profile, infer_profile_hint, props_json
|
||||
from app.vsphere.security.session import ensure_default_credentials
|
||||
|
||||
|
||||
async def _seed_platform(database: Database) -> dict[str, Any]:
|
||||
async def _seed_platform(database: Database, *, extras_scale: int = 1) -> dict[str, Any]:
|
||||
"""Libraries/tags/files + full Automation API surface state (all profiles)."""
|
||||
|
||||
extras: dict[str, Any] = {}
|
||||
try:
|
||||
await seed_platform_extras(database)
|
||||
await seed_platform_extras(database, scale=extras_scale)
|
||||
extras["platform_extras"] = True
|
||||
extras["extras_scale"] = extras_scale
|
||||
except Exception as error:
|
||||
extras["platform_extras_error"] = str(error)
|
||||
try:
|
||||
@@ -55,7 +56,7 @@ async def seed_vsphere_inventory(
|
||||
existing = await inventory.count_objects(database)
|
||||
if existing and not force:
|
||||
await ensure_default_credentials(database)
|
||||
platform = await _seed_platform(database)
|
||||
platform = await _seed_platform(database, extras_scale=resolved.extras_scale)
|
||||
by_type = await inventory.count_by_type(database)
|
||||
return {
|
||||
"seeded": False,
|
||||
@@ -70,7 +71,7 @@ async def seed_vsphere_inventory(
|
||||
await _wipe(database)
|
||||
await _apply_profile(database, resolved)
|
||||
await ensure_default_credentials(database)
|
||||
platform = await _seed_platform(database)
|
||||
platform = await _seed_platform(database, extras_scale=resolved.extras_scale)
|
||||
by_type = await inventory.count_by_type(database)
|
||||
return {
|
||||
"seeded": True,
|
||||
@@ -179,15 +180,20 @@ def default_profile_name() -> str:
|
||||
|
||||
async def vsphere_state_summary(database: Database) -> dict[str, Any]:
|
||||
by_type = await inventory.count_by_type(database)
|
||||
hosts = by_type.get("HostSystem", 0)
|
||||
vms = by_type.get("VirtualMachine", 0)
|
||||
datastores = by_type.get("Datastore", 0)
|
||||
networks = by_type.get("Network", 0) + by_type.get("DistributedVirtualPortgroup", 0)
|
||||
return {
|
||||
"hosts": by_type.get("HostSystem", 0),
|
||||
"vms": by_type.get("VirtualMachine", 0),
|
||||
"datastores": by_type.get("Datastore", 0),
|
||||
"hosts": hosts,
|
||||
"vms": vms,
|
||||
"datastores": datastores,
|
||||
"networks": networks,
|
||||
"datacenters": by_type.get("Datacenter", 0),
|
||||
"clusters": by_type.get("ClusterComputeResource", 0),
|
||||
"objects": sum(by_type.values()),
|
||||
"by_type": by_type,
|
||||
"profile_hint": default_profile_name(),
|
||||
"profile_hint": infer_profile_hint(hosts=hosts, vms=vms, datastores=datastores),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+741
-144
File diff suppressed because it is too large
Load Diff
+39
-8
@@ -229,12 +229,25 @@ async def ui_demo_state(request: Request) -> JSONResponse:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||
async with pool.acquire() as connection:
|
||||
pve = await simulation_state_summary(connection)
|
||||
return JSONResponse({"vsphere": vsphere, "proxmox_stub": pve})
|
||||
profile = vsphere.get("profile_hint") or "minimal"
|
||||
loaded = profile in {"small", "large", "big", "demo-cluster", "demo", "enterprise"}
|
||||
return JSONResponse(
|
||||
{
|
||||
"vsphere": vsphere,
|
||||
"proxmox_stub": pve,
|
||||
"loaded": loaded,
|
||||
"label": f"{vsphere.get('hosts', 0)} hosts · {vsphere.get('vms', 0)} VMs",
|
||||
"profile": profile,
|
||||
}
|
||||
)
|
||||
profile = vsphere.get("profile_hint") or "minimal"
|
||||
loaded = profile in {"small", "large", "big", "demo-cluster", "demo", "enterprise"}
|
||||
return JSONResponse(
|
||||
{
|
||||
"vsphere": vsphere,
|
||||
"loaded": vsphere.get("vms", 0) >= 100,
|
||||
"loaded": loaded,
|
||||
"label": f"{vsphere.get('hosts', 0)} hosts · {vsphere.get('vms', 0)} VMs",
|
||||
"profile": profile,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -245,17 +258,35 @@ async def ui_demo_load(request: Request) -> JSONResponse:
|
||||
|
||||
settings = _settings(request)
|
||||
summary: dict = {}
|
||||
profile_name = "demo-cluster"
|
||||
size_raw = request.query_params.get("size") or request.query_params.get("profile")
|
||||
if not size_raw:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if isinstance(body, dict):
|
||||
size_raw = body.get("size") or body.get("profile")
|
||||
profile_name = str(size_raw or "large").strip().lower() or "large"
|
||||
if profile_name in {"demo-cluster", "demo", "enterprise"}:
|
||||
profile_name = "big"
|
||||
if profile_name not in {"small", "large", "big"}:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"unknown size {profile_name!r}; sizes: small, large, big",
|
||||
)
|
||||
if settings is not None and getattr(settings, "enable_pve_stub", False):
|
||||
pool = _database_pool(request)
|
||||
profile = build_profile("demo-cluster")
|
||||
pve_name = "demo-cluster" if profile_name == "big" else profile_name
|
||||
try:
|
||||
profile = build_profile(pve_name)
|
||||
except Exception:
|
||||
profile = build_profile("demo-cluster")
|
||||
async with pool.acquire() as connection:
|
||||
await apply_seed(connection, profile)
|
||||
summary = await simulation_state_summary(connection)
|
||||
profile_name = profile.name
|
||||
try:
|
||||
vsphere = await seed_vsphere_inventory(
|
||||
get_database(request), force=True, profile="demo-cluster"
|
||||
get_database(request), force=True, profile=profile_name
|
||||
)
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=503, detail=f"database is not ready: {error}") from error
|
||||
@@ -271,7 +302,7 @@ async def ui_demo_unload(request: Request) -> JSONResponse:
|
||||
|
||||
settings = _settings(request)
|
||||
summary: dict = {}
|
||||
profile_name = "small"
|
||||
profile_name = "minimal"
|
||||
try:
|
||||
if settings is not None and getattr(settings, "enable_pve_stub", False):
|
||||
pool = _database_pool(request)
|
||||
@@ -280,7 +311,7 @@ async def ui_demo_unload(request: Request) -> JSONResponse:
|
||||
await apply_seed(connection, profile)
|
||||
summary = await simulation_state_summary(connection)
|
||||
profile_name = profile.name
|
||||
vsphere = await seed_vsphere_inventory(get_database(request), force=True, profile="small")
|
||||
vsphere = await seed_vsphere_inventory(get_database(request), force=True, profile="minimal")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
|
||||
Reference in New Issue
Block a user