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
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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",
}
+7
View File
@@ -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
View File
@@ -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`` (14) 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)
+47
View File
@@ -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":
+23 -9
View File
@@ -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,
+16 -4
View File
@@ -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
View File
@@ -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)
+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,
+15 -9
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+39 -8
View File
@@ -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: