Initial commit: VMware vSphere API simulator scaffold.

Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API
contracts, docs, client examples, and the unit/integration/compatibility
test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Native vSphere REST + SOAP simulation surface."""
+1
View File
@@ -0,0 +1 @@
"""Versioned vSphere REST coverage catalogs for the lab console."""
+304
View File
@@ -0,0 +1,304 @@
"""Native vSphere API catalog (replaces Proxmox stub catalog in the console)."""
from __future__ import annotations
import re
from typing import Any
from app.vsphere.contracts.matrix import (
VERSIONS,
catalog_entries_for_major,
is_implemented_for_major,
load_bundle,
)
_PATH_PARAM = re.compile(r"\{([^{}/]+)\}")
_PATH_EXAMPLES: dict[str, str] = {
"vm": "vm-111",
"host": "host-11",
"datastore": "datastore-31",
"task": "task-1",
"snapshot": "snapshot-1",
"category_id": "urn:vmomi:InventoryServiceCategory:demo:GLOBAL",
"tag_id": "urn:vmomi:InventoryServiceTag:demo:GLOBAL",
"item_id": "item-demo",
"folder": "group-v23",
"datacenter": "datacenter-21",
"cluster": "domain-c21",
"resource_pool": "resgroup-22",
"permission_id": "1",
"policy": "policy-default",
}
# 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"}',
},
],
}
def list_vsphere_majors(*, runtime_version: str | None) -> dict[str, Any]:
return {
"runtime_version": runtime_version or VERSIONS[9]["version"],
"plane": "vsphere-rest",
"majors": [
{
"major": major,
"series": meta["series"],
"latest_version": meta["version"],
"artifact_url": f"stub://vmware/vsphere-{meta['version']}/api-contract",
"bundled": True,
}
for major, meta in VERSIONS.items()
],
}
def vsphere_catalog_payload(major: int) -> dict[str, Any]:
meta = VERSIONS.get(major) or VERSIONS[9]
bundle = load_bundle(major)
entries = catalog_entries_for_major(major)
grouped: dict[str, dict[str, dict[str, Any]]] = {}
for entry in entries:
path = entry["path"]
parts = [p for p in path.split("/") if p]
tag = "/".join(parts[:3]) if len(parts) >= 3 else path
by_path = grouped.setdefault(tag, {})
path_entry = by_path.setdefault(path, {"path": path, "methods": []})
path_entry["methods"].append(
{
"verb": entry["verb"],
"name": f"{entry['verb'].lower()}_{parts[-1] if parts else 'root'}",
"description": f"{entry['status']} {entry['verb']} {path}",
"protected": True,
"implemented": entry["status"] in {"implemented", "stub"},
}
)
categories = [
{
"tag": tag,
"paths": sorted(by_path.values(), key=lambda item: item["path"]),
}
for tag, by_path in sorted(grouped.items())
]
return {
"major": major,
"series": meta["series"],
"source_version": meta["version"],
"latest_version": meta["version"],
"artifact_url": f"stub://vmware/vsphere-{meta['version']}/api-contract",
"bundled": True,
"path_count": sum(len(cat["paths"]) for cat in categories),
"method_count": len(entries),
"categories": categories,
"plane": "vsphere-rest",
"contract_kind": bundle.get("kind", "stub-openapi-matrix"),
}
def _field(
name: str,
*,
type_name: str = "string",
optional: bool = False,
example: Any = None,
description: str | None = None,
enum: list[str] | None = None,
) -> dict[str, Any]:
return {
"name": name,
"type": type_name,
"description": description,
"optional": optional,
"enum": enum or [],
"example": example if example is not None else name,
}
def _path_fields(path: str) -> list[dict[str, Any]]:
fields = []
for name in _PATH_PARAM.findall(path):
fields.append(
_field(
name,
optional=False,
example=_PATH_EXAMPLES.get(name, name),
description=f"Path parameter {{{name}}}",
)
)
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"):
continue
example = field.get("example")
if isinstance(example, str) and example.startswith("{"):
try:
import json
body[field["name"]] = json.loads(example)
continue
except Exception:
body[field["name"]] = example
continue
body[field["name"]] = example
return body
def vsphere_method_payload(
*,
major: int,
path: str,
verb: str,
runtime_version: str | None,
) -> dict[str, Any]:
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",
)
)
resolved = path
for field in path_fields:
resolved = resolved.replace(f"{{{field['name']}}}", str(field["example"]))
return {
"major": major,
"path": path,
"verb": upper,
"name": path.strip("/").replace("/", "_"),
"description": f"{upper} {path}",
"resolved_path": resolved,
"path_fields": path_fields,
"body_fields": body_fields,
"indexed_fields": [],
"body_example": _body_example_from_fields(body_fields),
"implemented": is_implemented_for_major(upper, path, major),
"runtime_version": runtime_version or meta["version"],
"source_version": meta["version"],
}
+148
View File
@@ -0,0 +1,148 @@
"""Compatibility / Implementation-coverage payload for the lab UI."""
from __future__ import annotations
from collections import Counter
from pathlib import Path
from typing import Any
from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major
from app.vsphere.rest.coverage import catalog_entries
_EVIDENCE_ROOT = Path(__file__).resolve().parents[3] / "evidence"
def _level(count: int, total: int) -> dict[str, Any]:
total = max(total, 1)
return {"count": count, "score": round(count / total, 4)}
def vsphere_compatibility_payload(
major: int,
*,
runtime_version: str | None = None,
) -> dict[str, Any]:
"""Shape expected by lab UI ``updateCatalogCoverage`` / compatibility help panel."""
meta = VERSIONS.get(major) or VERSIONS[9]
universe = catalog_entries()
active = catalog_entries_for_major(major)
declared = len(universe)
implemented = len(active)
unsupported = max(declared - implemented, 0)
by_verb = Counter(e["verb"] for e in active)
universe_by_verb = Counter(e["verb"] for e in universe)
# Surface matrix treats every registered route as exercised for the active floor.
observed = implemented
verified = implemented
dimensions = {
"route_method": _level(implemented, declared),
"get": _level(by_verb.get("GET", 0), max(universe_by_verb.get("GET", 0), 1)),
"post": _level(by_verb.get("POST", 0), max(universe_by_verb.get("POST", 0), 1)),
"patch": _level(by_verb.get("PATCH", 0), max(universe_by_verb.get("PATCH", 0), 1)),
"delete": _level(by_verb.get("DELETE", 0), max(universe_by_verb.get("DELETE", 0), 1)),
"auth_session": _level(
sum(1 for e in active if e["path"] in {"/api/session", "/rest/com/vmware/cis/session"}),
6,
),
"inventory": _level(
sum(1 for e in active if "/api/vcenter/" in e["path"]),
max(sum(1 for e in universe if "/api/vcenter/" in e["path"]), 1),
),
"legacy_rest": _level(
sum(1 for e in active if e["path"].startswith("/rest/")),
max(sum(1 for e in universe if e["path"].startswith("/rest/")), 1),
),
}
evidence_path = _EVIDENCE_ROOT / f"vsphere-{meta['version']}.json"
evidence_summary: dict[str, Any] = {}
if evidence_path.is_file():
try:
import json
evidence_summary = (
json.loads(evidence_path.read_text(encoding="utf-8")).get("summary") or {}
)
except Exception:
evidence_summary = {}
active_keys = {(a["verb"], a["path"]) for a in active}
gated_entries = [
f"{e['verb']} {e['path']}" for e in universe if (e["verb"], e["path"]) not in active_keys
]
return {
"major": major,
"series": meta["series"],
"source_version": meta["version"],
"catalog_version": meta["version"],
"runtime_version": runtime_version or meta["version"],
"plane": "vsphere-rest",
"evidence_scope": "vsphere-registry",
"total_declared": declared,
"levels": {
"declared": _level(declared, declared),
# Prefer "gated" in the help UI; keep schema_only as an alias for older clients.
"gated": _level(unsupported, declared),
"schema_only": _level(unsupported, declared),
"implemented": _level(implemented, declared),
"observed": _level(observed, declared),
"verified": _level(verified, declared),
},
"dimensions": dimensions,
"classifications": {
"available": [f"{e['verb']} {e['path']}" for e in active],
"fully_compatible": [f"{e['verb']} {e['path']}" for e in active],
"partially_compatible": [],
"incompatible": [],
"regressions": [],
"unsupported": gated_entries,
"gated_501": gated_entries,
},
"summary": {
"methods": declared,
"implemented": implemented,
"unsupported_in_version": unsupported,
"coverage": round(implemented / max(declared, 1), 4),
"by_verb": dict(sorted(by_verb.items())),
"universe_by_verb": dict(sorted(universe_by_verb.items())),
**{k: v for k, v in evidence_summary.items() if k.startswith("probed")},
},
"entries": active,
}
def evidence_ledger(major: int) -> dict[str, Any]:
"""Compact on-disk ledger written by ``scripts/write_vsphere_evidence.py``."""
from datetime import UTC, datetime
payload = vsphere_compatibility_payload(major)
meta = VERSIONS[major]
return {
"product": "vmware-api-simulator",
"api_version": meta["version"],
"major": major,
"series": meta["series"],
"plane": "vsphere-rest",
"generated_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"notes": (
"Ledger derived from app/vsphere/rest/coverage.py + PATH_FLOOR. "
"implemented_methods = available at this major; "
"universe_methods = full simulator registry."
),
"summary": {
"implemented_methods": payload["summary"]["implemented"],
"universe_methods": payload["summary"]["methods"],
"unsupported_in_version": payload["summary"]["unsupported_in_version"],
"coverage": payload["summary"]["coverage"],
"by_verb": payload["summary"]["by_verb"],
"status": "partial-clone"
if payload["summary"]["coverage"] < 1
else "registry-complete",
},
"levels": payload["levels"],
"dimensions": payload["dimensions"],
}
+244
View File
@@ -0,0 +1,244 @@
"""Per-major vSphere REST availability matrix (stub OpenAPI stand-in)."""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
from typing import Any
from app.vsphere.rest.coverage import ACTIVE_STATUSES, IMPLEMENTED
# Integer majors mirror the console hot-swap ids (6-9).
VERSIONS: dict[int, dict[str, str]] = {
6: {"series": "vSphere 7.0", "version": "7.0.0"},
7: {"series": "vSphere 7.0 U3", "version": "7.0.3"},
8: {"series": "vSphere 8.0", "version": "8.0.0"},
9: {"series": "vSphere 8.0 U2", "version": "8.0.2"},
}
# Minimum major at which a registered path is considered implemented.
# Anything absent defaults to major 9 (latest) so new paths stay opt-in until catalogued.
_DEFAULT_FLOOR = 9
PATH_FLOOR: dict[tuple[str, str], int] = {
# Always-on core (7.0+)
("POST", "/api/session"): 6,
("DELETE", "/api/session"): 6,
("GET", "/api/session"): 6,
("POST", "/rest/com/vmware/cis/session"): 6,
("GET", "/rest/com/vmware/cis/session"): 6,
("DELETE", "/rest/com/vmware/cis/session"): 6,
("GET", "/api/appliance/system/version"): 6,
("GET", "/api/vcenter/vm"): 6,
("POST", "/api/vcenter/vm"): 6,
("GET", "/api/vcenter/vm/{vm}"): 6,
("DELETE", "/api/vcenter/vm/{vm}"): 6,
("POST", "/api/vcenter/vm/{vm}/power"): 6,
("GET", "/api/vcenter/vm/{vm}/guest/identity"): 6,
("GET", "/api/vcenter/host"): 6,
("GET", "/api/vcenter/host/{host}"): 6,
("GET", "/api/vcenter/datastore"): 6,
("GET", "/api/vcenter/datastore/{datastore}"): 6,
("GET", "/api/vcenter/network"): 6,
("GET", "/api/vcenter/datacenter"): 6,
("GET", "/api/vcenter/cluster"): 6,
("GET", "/api/vcenter/folder"): 6,
("GET", "/api/vcenter/resource-pool"): 6,
# 7.0 U3 depth
("GET", "/api/cis/tasks"): 7,
("GET", "/api/cis/tasks/{task}"): 7,
("GET", "/api/vcenter/vm/{vm}/tools"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/cpu"): 7,
("PATCH", "/api/vcenter/vm/{vm}/hardware/cpu"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/memory"): 7,
("PATCH", "/api/vcenter/vm/{vm}/hardware/memory"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/disk"): 7,
("POST", "/api/vcenter/vm/{vm}/hardware/disk"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/ethernet"): 7,
("POST", "/api/vcenter/vm/{vm}/hardware/ethernet"): 7,
("GET", "/api/vcenter/vm/{vm}/hardware/boot"): 7,
("GET", "/api/vcenter/vm/{vm}/snapshots"): 7,
("POST", "/api/vcenter/vm/{vm}/snapshots"): 7,
("DELETE", "/api/vcenter/vm/{vm}/snapshots/{snapshot}"): 7,
("POST", "/api/vcenter/vm/{vm}/snapshots/{snapshot}"): 7,
("POST", "/api/vcenter/vm/{vm}/clone"): 7,
("POST", "/api/vcenter/vm/{vm}/relocate"): 7,
("POST", "/api/vcenter/host/{host}/maintenance"): 7,
("GET", "/api/vcenter/datastore/{datastore}/files"): 7,
("POST", "/api/vcenter/datastore/{datastore}/files"): 7,
("POST", "/api/vcenter/datacenter"): 7,
("DELETE", "/api/vcenter/datacenter/{datacenter}"): 7,
("POST", "/api/vcenter/cluster"): 7,
("DELETE", "/api/vcenter/cluster/{cluster}"): 7,
("POST", "/api/vcenter/folder"): 7,
("POST", "/api/vcenter/folder/{folder}"): 7,
("DELETE", "/api/vcenter/folder/{folder}"): 7,
("POST", "/api/vcenter/resource-pool"): 7,
("DELETE", "/api/vcenter/resource-pool/{resource_pool}"): 7,
("GET", "/api/cis/tagging/category"): 7,
("POST", "/api/cis/tagging/category"): 7,
("GET", "/api/cis/tagging/category/{category_id}"): 7,
("DELETE", "/api/cis/tagging/category/{category_id}"): 7,
("GET", "/api/cis/tagging/tag"): 7,
("POST", "/api/cis/tagging/tag"): 7,
("GET", "/api/cis/tagging/tag/{tag_id}"): 7,
("DELETE", "/api/cis/tagging/tag/{tag_id}"): 7,
("POST", "/api/cis/tagging/tag-association"): 7,
# 8.0 platform services
("GET", "/api/appliance/health/system"): 8,
("GET", "/api/appliance/networking"): 8,
("GET", "/api/appliance/timesync"): 8,
("GET", "/api/vcenter/network/dvs"): 8,
("POST", "/api/vcenter/network/dvs"): 8,
("POST", "/api/vcenter/network/dvpg"): 8,
("GET", "/api/content/library"): 8,
("POST", "/api/content/local-library"): 8,
("GET", "/api/content/library/item"): 8,
("POST", "/api/content/library/item"): 8,
("POST", "/api/vcenter/ovf/library-item/{item_id}"): 8,
("GET", "/api/vcenter/storage/policies"): 8,
("GET", "/api/vcenter/storage/policies/{policy}/vm"): 8,
("GET", "/api/vcenter/privilege"): 8,
("GET", "/api/vcenter/authorization/roles"): 8,
("GET", "/api/vcenter/authorization/permissions"): 8,
("POST", "/api/vcenter/authorization/permissions"): 8,
("DELETE", "/api/vcenter/authorization/permissions/{permission_id}"): 8,
("GET", "/api/vcenter/identity/providers"): 8,
("GET", "/api/vcenter/certificate-management/vcenter/tls"): 9,
("GET", "/api/vcenter/vm/{vm}/guest/networking"): 7,
("GET", "/api/vcenter/vm/{vm}/guest/power"): 7,
("POST", "/api/vcenter/vm/{vm}/guest/power"): 7,
("POST", "/api/vcenter/vm/{vm}/tools"): 7,
("POST", "/api/vcenter/vm/{vm}/console/tickets"): 8,
("POST", "/api/vcenter/vm/{vm}/guest/customization"): 8,
("POST", "/api/vcenter/vm/{vm}"): 7,
("GET", "/api/vcenter/host/{host}/storage/storage-device"): 8,
("GET", "/api/vcenter/host/{host}/networking"): 8,
("GET", "/api/vcenter/folder/{folder}/children"): 7,
("GET", "/api/vapi/metadata/metamodel/service"): 8,
("GET", "/api/vapi/metadata/authentication/component"): 8,
("GET", "/api/vcenter/activity-history"): 8,
("GET", "/rest/vcenter/vm"): 6,
("GET", "/rest/vcenter/vm/{vm}"): 6,
("POST", "/rest/vcenter/vm/{vm}/power"): 6,
("GET", "/rest/vcenter/host"): 6,
("GET", "/rest/vcenter/datastore"): 6,
("GET", "/rest/vcenter/network"): 6,
("GET", "/rest/vcenter/datacenter"): 6,
("GET", "/rest/vcenter/cluster"): 6,
("GET", "/rest/appliance/system/version"): 6,
}
def floor_for(verb: str, path: str) -> int:
return PATH_FLOOR.get((verb.upper(), path), _DEFAULT_FLOOR)
def methods_for_major(major: int) -> dict[tuple[str, str], str]:
active = major if major in VERSIONS else 9
return {
key: status for key, status in IMPLEMENTED.items() if floor_for(key[0], key[1]) <= active
}
def catalog_entries_for_major(major: int) -> list[dict[str, str]]:
return [
{"verb": verb, "path": path, "status": status}
for (verb, path), status in sorted(methods_for_major(major).items())
]
def is_implemented_for_major(verb: str, path: str, major: int) -> bool:
return methods_for_major(major).get((verb.upper(), path)) in ACTIVE_STATUSES
@lru_cache(maxsize=1)
def _compiled_routes() -> list[tuple[str, re.Pattern[str], str, int, int]]:
compiled: list[tuple[str, re.Pattern[str], str, int, int]] = []
for verb, path in IMPLEMENTED:
pattern = "^" + re.sub(r"\{[^/]+\}", r"[^/]+", path) + "$"
parts = [part for part in path.split("/") if part]
static = sum(1 for part in parts if not (part.startswith("{") and part.endswith("}")))
compiled.append((verb, re.compile(pattern), path, static, len(path)))
# Prefer more literal segments so /library/item beats /library/{library_id}.
compiled.sort(key=lambda item: (item[0], -item[3], -item[4], item[2]))
return compiled
def resolve_template(verb: str, request_path: str) -> str | None:
method = verb.upper()
for route_verb, pattern, template, _static, _length in _compiled_routes():
if route_verb == method and pattern.match(request_path):
return template
return None
def available_for_request(verb: str, request_path: str, major: int) -> bool | None:
"""True = allowed, False = known but wrong version, None = not in registry.
Lab policy: every registered Automation API method is always served with real
(DB-backed) handlers/stubs regardless of the hot-swapped catalog major.
Catalog browse still uses ``methods_for_major`` / PATH_FLOOR for history.
"""
del major # major retained for call-site compatibility; gating is catalog-only
template = resolve_template(verb, request_path)
if template is None:
return None
return True
def bundle_payload(major: int) -> dict[str, Any]:
meta = VERSIONS.get(major) or VERSIONS[9]
entries = catalog_entries_for_major(major)
return {
"product": "vmware-api-simulator",
"plane": "vsphere-rest",
"major": major,
"series": meta["series"],
"version": meta["version"],
"kind": "stub-openapi-matrix",
"notes": (
"Stub contract derived from app/vsphere/rest/coverage.py + PATH_FLOOR. "
"Not a Broadcom OpenAPI dump; used for catalog browse and hot-swap gating."
),
"method_count": len(entries),
"methods": entries,
}
def bundles_root() -> Path:
return Path(__file__).resolve().parents[3] / "contracts" / "vsphere"
def write_bundles(root: Path | None = None) -> list[Path]:
base = root or bundles_root()
written: list[Path] = []
for major in sorted(VERSIONS):
meta = VERSIONS[major]
directory = base / meta["version"]
directory.mkdir(parents=True, exist_ok=True)
payload = bundle_payload(major)
path = directory / "manifest.json"
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
written.append(path)
(base / "README.md").write_text(
"# vSphere stub contracts\n\n"
"Versioned JSON matrices generated from `app/vsphere/contracts/matrix.py`.\n"
"Hot-swap (`POST /ui/api/contract/apply?major=N`) switches the catalog major "
"for UI browse/evidence. Runtime always serves the full registered surface "
"(no HTTP 501 version gate on known paths).\n",
encoding="utf-8",
)
return written
def load_bundle(major: int) -> dict[str, Any]:
meta = VERSIONS.get(major) or VERSIONS[9]
path = bundles_root() / meta["version"] / "manifest.json"
if path.is_file():
return json.loads(path.read_text(encoding="utf-8"))
return bundle_payload(major)
+1
View File
@@ -0,0 +1 @@
"""Shared business operations for REST and SOAP surfaces."""
+763
View File
@@ -0,0 +1,763 @@
"""DB-backed Automation API surface state (keyed by verb + path template)."""
from __future__ import annotations
import json
import secrets
from typing import Any
from app.db.pool import Database
from app.vsphere import inventory
_STATE_SQL = """
INSERT INTO vsphere_api_state (state_key, verb, path_template, payload, seed_payload, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $4::jsonb, now())
ON CONFLICT (state_key) DO UPDATE
SET payload = EXCLUDED.payload, updated_at = now()
"""
_SEED_STATE_SQL = """
INSERT INTO vsphere_api_state (state_key, verb, path_template, payload, seed_payload, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $4::jsonb, now())
ON CONFLICT (state_key) DO UPDATE
SET payload = EXCLUDED.payload,
seed_payload = EXCLUDED.seed_payload,
updated_at = now()
"""
def state_key(verb: str, path_template: str) -> str:
return f"{verb.upper()} {path_template}"
def _pool(database: Database) -> Any:
return database.pool # type: ignore[attr-defined]
def _decode_json(value: Any) -> Any:
"""asyncpg may return jsonb as str depending on codec configuration."""
current = value
while isinstance(current, str):
try:
current = json.loads(current)
except json.JSONDecodeError:
break
return current
def is_empty_payload(payload: Any) -> bool:
if payload is None or payload == "" or payload == {} or payload == []:
return True
if isinstance(payload, dict):
for key in ("data", "value", "messages", "items", "results"):
if key in payload and payload[key] in ([], None, {}):
return True
return False
async def get_payload(database: Database, verb: str, path_template: str) -> Any | None:
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT payload FROM vsphere_api_state WHERE state_key = $1",
state_key(verb, path_template),
)
if row is None:
return None
return _decode_json(row["payload"])
async def get_payload_or_seed(database: Database, verb: str, path_template: str) -> Any | None:
"""Return runtime payload, restoring seed_payload from DB when missing/empty."""
payload = await get_payload(database, verb, path_template)
if not is_empty_payload(payload):
return payload
restored = await restore_seed_payload(database, verb, path_template)
if restored is not None:
return restored
return payload
async def put_payload(database: Database, verb: str, path_template: str, payload: Any) -> None:
"""Update runtime payload; preserves existing seed_payload baseline when present."""
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
_STATE_SQL,
state_key(verb, path_template),
verb.upper(),
path_template,
json.dumps(payload),
)
async def put_seed_payload(database: Database, verb: str, path_template: str, payload: Any) -> None:
"""Write both runtime payload and immutable seed baseline (used only by seed)."""
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
_SEED_STATE_SQL,
state_key(verb, path_template),
verb.upper(),
path_template,
json.dumps(payload),
)
async def restore_seed_payload(database: Database, verb: str, path_template: str) -> Any | None:
"""Restore payload from DB seed_payload baseline (no Python templates)."""
pool = _pool(database)
key = state_key(verb, path_template)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT seed_payload FROM vsphere_api_state WHERE state_key = $1",
key,
)
if row is None or row["seed_payload"] is None:
return None
payload = _decode_json(row["seed_payload"])
await conn.execute(
"""
UPDATE vsphere_api_state
SET payload = seed_payload, updated_at = now()
WHERE state_key = $1
""",
key,
)
return payload
async def delete_payload(database: Database, verb: str, path_template: str) -> bool:
"""Remove a leaf resource document; collection roots use restore_seed_payload instead."""
pool = _pool(database)
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM vsphere_api_state WHERE state_key = $1",
state_key(verb, path_template),
)
return result.endswith("1")
async def list_collection(database: Database, path_template: str) -> list[Any]:
"""Return GET payload for a collection path; always a list."""
payload = await get_payload(database, "GET", path_template)
if isinstance(payload, list):
return payload
if payload is None:
return []
return [payload]
def _lab_row(leaf: str, path: str, **extra: Any) -> dict[str, Any]:
return {
"id": f"{leaf}-lab-1",
"name": f"{leaf}-lab-1",
"status": "ENABLED",
"path": path,
**extra,
}
def ensure_nonempty(payload: Any, path: str) -> Any:
"""Guarantee lab payloads are never empty lists/objects/null (incl. nested data)."""
if payload is None or payload == "" or payload == {} or payload == []:
return default_for_get_path(path)
if isinstance(payload, dict) and len(payload) == 0:
return default_for_get_path(path)
if isinstance(payload, list) and len(payload) == 0:
return default_for_get_path(path)
if isinstance(payload, dict):
# Fill known nested empties that clients treat as "no data".
out = dict(payload)
changed = False
for key in ("data", "value", "messages", "items", "results"):
if key in out and out[key] in ([], None, {}):
filled = default_for_get_path(path)
if (
key == "messages"
and isinstance(filled, dict)
and isinstance(filled.get("messages"), list)
):
out[key] = filled["messages"]
elif isinstance(filled, list):
out[key] = filled
elif key in {"data", "value"}:
# Prefer wrapping a realistic list when the key is a collection.
out[key] = filled
if out[key] in ([], None, {}):
out[key] = [{"id": "lab-1", "name": "lab", "path": path}]
else:
out[key] = [{"id": "lab-ok", "default_message": "Healthy", "args": []}]
changed = True
if changed:
return out
return payload
def default_for_get_path(path: str) -> Any:
"""Realistic lab defaults — never returns empty list/object/null."""
leaf = path.rstrip("/").rsplit("/", 1)[-1]
if leaf.startswith("{") and leaf.endswith("}"):
name = leaf[1:-1]
return {
"id": f"lab-{name}",
"name": f"lab-{name}",
"path": path,
"status": "ENABLED",
}
if "/appliance/access/ssh" in path:
return {"enabled": True}
if "/appliance/access/dcui" in path:
return {"enabled": True}
if "/appliance/access/consolecli" in path:
return {"enabled": True}
if "/appliance/access/shell" in path:
return {"enabled": True, "timeout": 300}
if path.endswith("/dns/hostname"):
return {"name": "vcenter.lab.local"}
if path.endswith("/dns/servers"):
return {"mode": "DHCP", "servers": ["8.8.8.8", "1.1.1.1"]}
if path.endswith("/dns/domains"):
return ["lab.local", "vsphere.local"]
if "/appliance/timesync" in path:
return {"mode": "NTP", "servers": ["time.lab.local"]}
if "/appliance/networking" in path:
return {
"hostname": "vcenter.lab.local",
"node_name": "vcenter.lab.local",
"default_gateway": "192.168.1.1",
"dns": {"mode": "DHCP", "servers": ["8.8.8.8"], "domains": ["lab.local"]},
"interfaces": [
{"name": "nic0", "status": "up", "ipv4": {"address": "192.168.1.50", "prefix": 24}}
],
}
if "/appliance/health" in path:
return {"status": "green", "messages": [{"id": "ok", "default_message": "Healthy"}]}
if "/appliance/update" in path:
return {"state": "UP_TO_DATE", "version": "8.0.2"}
if "/appliance/recovery" in path:
return {"status": "IDLE", "parts": [{"part": "VCSA", "status": "OK"}]}
if "/appliance/system/storage" in path:
return [{"disk": "sda", "capacity": 100000000000, "used": 40000000000}]
if "/appliance/system/time" in path:
return {"seconds_since_epoch": 1767225600, "datetime": "2026-01-01T00:00:00.000Z"}
if "/certificate-management" in path:
return {
"cert": "-----BEGIN CERTIFICATE-----\nMIIBstub\n-----END CERTIFICATE-----",
"valid_from": "2026-01-01T00:00:00.000Z",
"valid_to": "2028-01-01T00:00:00.000Z",
}
if "/identity/providers" in path:
return [{"provider": "vsphere.local", "name": "vsphere.local", "type_id": "LocalOS"}]
if "/crypto-manager" in path or "/crypto/" in path:
return [{"provider": "native-kms", "type": "NATIVE", "status": "READY"}]
if "/activity-history" in path:
return [
{
"activity": "activity-lab-1",
"description": "Seed inventory",
"status": "SUCCEEDED",
"start_time": "2026-01-01T00:00:00.000Z",
"user": "administrator@vsphere.local",
}
]
if "/namespace-management/virtual-machine-classes" in path:
return [
{
"id": "best-effort-small",
"cpu_count": 2,
"memory_mb": 2048,
"description": "Lab small class",
},
{
"id": "guaranteed-large",
"cpu_count": 8,
"memory_mb": 16384,
"description": "Lab large class",
},
]
if "/namespace-management/supervisors" in path or "/namespaces/" in path:
if "{" in path:
return {
"supervisor": "supervisor-1",
"name": "supervisor-lab",
"config_status": "RUNNING",
"kubernetes_status": "READY",
}
return [
{
"supervisor": "supervisor-1",
"name": "supervisor-lab",
"config_status": "RUNNING",
"kubernetes_status": "READY",
}
]
if "/namespace-management" in path:
return [
{
"cluster": "domain-c21",
"cluster_name": "Cluster",
"config_status": "RUNNING",
"kubernetes_status": "READY",
}
]
if "/esx/settings" in path:
if "{" in path and not path.endswith("}"):
return {
"status": "COMPLIANT",
"software_info": {
"base_image": {"version": "8.0.2-0.0"},
"components": [{"name": "VMware-VMTools", "version": "12.0"}],
},
}
return [{"cluster": "domain-c21", "status": "COMPLIANT", "commit": "commit-lab-1"}]
if "/trusted-infrastructure" in path or "/trustedinfrastructure" in path:
return [{"cluster": "domain-c21", "state": "ENABLED", "attestation": "READY"}]
if "/services/service" in path or path.endswith("/services"):
return [
{"service": "vsphere-ui", "state": "STARTED", "description": "vSphere Client"},
{"service": "vpxd", "state": "STARTED", "description": "vCenter Server"},
{"service": "vapi-endpoint", "state": "STARTED", "description": "vAPI Endpoint"},
]
if "/storage/policies" in path:
return [
{
"policy": "policy-default",
"name": "vSAN Default Storage Policy",
"description": "Lab default",
},
{"policy": "policy-thin", "name": "Thin provision", "description": "Thin disks"},
]
if "/guest/customization-specs" in path or "/guest/customization" in path:
return [
{"name": "linux-lab", "description": "Linux cloud-init lab spec", "os_type": "LINUX"}
]
if "/vcha" in path:
return {"mode": "DISABLED", "cluster_mode": "DISABLED"}
if "/content/registries/health" in path:
return [{"registry": "harbor-lab-1", "status": "HEALTHY", "details": "ok"}]
if "/content/registries/harbor" in path:
return [{"registry": "harbor-lab-1", "name": "harbor-lab", "version": "2.9"}]
if "/content/security-policies" in path:
return [{"policy": "sec-policy-lab-1", "name": "Default security", "status": "ENABLED"}]
if "/content/trusted-certificates" in path:
return [
{
"certificate": "cert-lab-1",
"name": "lab-ca",
"valid_to": "2028-01-01T00:00:00.000Z",
}
]
if path.endswith("/content/type") or path.endswith("/content/types"):
return [
{"type": "ovf", "description": "OVF template"},
{"type": "iso", "description": "ISO image"},
{"type": "vm-template", "description": "VM template"},
]
if path.rstrip("/") == "/api/content/library":
return ["lib-local-1", "lib-published-1"]
if path.rstrip("/") == "/api/content/library/item":
return ["item-ubuntu", "item-centos"]
if "/content/" in path:
if "download-session" in path or "update-session" in path:
if path.endswith("/file"):
return [
{
"name": "descriptor.ovf",
"size": 256,
"status": "READY",
"download_endpoint": {
"uri": "/api/content/library/item/download-session/session-lab-1/file/descriptor.ovf"
},
}
]
return {
"id": "session-lab-1",
"library_item_id": "item-ubuntu",
"state": "ACTIVE",
"name": "lab-session",
}
if leaf in {"file", "changes", "storage"}:
return [
{
"name": "descriptor.ovf",
"size": 4096,
"checksum_info": {"algorithm": "SHA256", "checksum": "lab"},
"storage_uris": ["ds:///vmfs/volumes/datastore-31/content/descriptor.ovf"],
"version": "1",
"cached": True,
}
]
if leaf in {"item", "library", "local-library", "subscribed-library"}:
return [_lab_row(leaf, path, type="LOCAL")]
return [_lab_row(leaf or "content", path)]
if "/cis/tagging/category" in path and "{" not in path:
return ["cat-lab-1", "urn:vmomi:InventoryServiceCategory:environment:GLOBAL"]
if "/cis/tagging/tag" in path and "{" not in path and "association" not in path:
return ["tag-lab-1", "urn:vmomi:InventoryServiceTag:prod:GLOBAL"]
if "/cis/tagging" in path:
return [_lab_row("tagging", path, category_id="cat-lab-1")]
if "/vcenter/vm/" in path and "/hardware/adapter/nvme" in path:
return [{"adapter": "19000", "bus": 0, "pci_slot_number": 160}]
if "/vcenter/vm/" in path and "/hardware/adapter/sata" in path:
return [{"adapter": "15000", "bus": 0, "pci_slot_number": 33}]
if "/vcenter/vm/" in path and "/hardware/parallel" in path:
return [{"port": "10000", "yield_on_poll": True}]
if "/vcenter/vm/" in path and "/hardware/" in path:
return [_lab_row(leaf or "device", path, key="2000")]
if "/vcenter/vm/" in path and "/guest/" in path:
return {
"family": "LINUX",
"full_name": {"name": "Ubuntu Linux (64-bit)"},
"host_name": "lab-guest",
"ip_address": "192.168.1.100",
}
if "/vcenter/host/" in path:
return {"connection_state": "CONNECTED", "power_state": "POWERED_ON", "status": "green"}
# VM power state is served by GET /api/vcenter/vm/{vm}/power (live inventory).
if "/stats/" in path or "/metrics" in path:
return {
"interval": "PT5M",
"data_points": [{"time": "2026-01-01T00:00:00.000Z", "value": 1.0}],
}
if "/vapi/metadata/authentication" in path:
return [
"com.vmware.cis.session",
"com.vmware.vcenter",
"com.vmware.appliance",
"com.vmware.content",
]
if "/vapi/metadata" in path:
return [
"com.vmware.vcenter",
"com.vmware.appliance",
"com.vmware.cis",
"com.vmware.content",
]
if any(token in leaf for token in ("list",)) or (
"{" not in path and leaf not in {"version", "system", "networking", "timesync"}
):
if path.count("{") == 0 and leaf not in {
"ssh",
"dcui",
"shell",
"consolecli",
"timesync",
"version",
"networking",
"system",
"tls",
"evc-mode",
"hostname",
"servers",
"domains",
}:
return [_lab_row(leaf, path)]
return {
"id": f"lab-{leaf}",
"name": f"lab-{leaf}",
"status": "ENABLED",
"path": path,
"value": True,
}
async def seed_api_surface(database: Database) -> dict[str, int]:
"""Populate vsphere_api_state for every GET route in the Broadcom universe + lab extras.
Templates in ``default_for_get_path`` are used ONLY here at seed time — request handlers
must read ``vsphere_api_state`` / domain tables without inventing payloads.
"""
from app.vsphere.rest.coverage import IMPLEMENTED
from app.vsphere.security.authz import PRIVILEGES, ROLE_PRIVILEGES
hosts = await inventory.list_objects(database, type_name="HostSystem")
clusters = await inventory.list_objects(database, type_name="ClusterComputeResource")
vms = await inventory.list_objects(database, type_name="VirtualMachine")
datastores = await inventory.list_objects(database, type_name="Datastore")
host_ids = [h.moid for h in hosts] or ["host-11"]
cluster_ids = [c.moid for c in clusters] or ["domain-c21"]
vm_ids = [v.moid for v in vms[:50]] or ["vm-101"]
ds_ids = [d.moid for d in datastores] or ["datastore-31"]
# Inventory-derived collections overwrite generic defaults.
extras: dict[tuple[str, str], Any] = {
("GET", "/api/vcenter/privilege"): [
{"id": key, "name": name, "description": name}
for key, name in sorted(PRIVILEGES.items())
],
("GET", "/api/vcenter/authorization/roles"): [
{"role": role, "privileges": sorted(privs)}
for role, privs in sorted(ROLE_PRIVILEGES.items())
],
("GET", "/api/appliance/health/system"): {
"status": "green",
"value": "green",
"messages": [{"id": "ok", "default_message": "Healthy", "args": []}],
},
("GET", "/api/appliance/system/version"): {
"version": "8.0.2",
"product": "VMware vCenter Server",
"type": "vCenter Server",
"build": "simulator",
"install_time": "2026-01-01T00:00:00.000Z",
"releasedate": "2026-01-01",
"summary": "VMware API Simulator",
},
("GET", "/api/vcenter/certificate-management/vcenter/tls-csr"): {
"csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBLabCSR\n-----END CERTIFICATE REQUEST-----",
"status": "AVAILABLE",
"subject_dn": "CN=vcenter.lab.local",
},
("GET", "/api/vcenter/certificate-management/vcenter/trusted-root-chains"): [
{
"chain": "chain-lab-1",
"cert_chain": ["-----BEGIN CERTIFICATE-----\nMIIBRoot\n-----END CERTIFICATE-----"],
"thumbprint": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD",
}
],
("GET", "/api/vapi/metadata/metamodel/service"): [
"com.vmware.vcenter.vm",
"com.vmware.vcenter.host",
"com.vmware.cis.tagging.category",
"com.vmware.cis.tagging.tag",
"com.vmware.content.library",
],
("GET", "/api/vapi/metadata/authentication/component"): [
"com.vmware.cis.session",
"com.vmware.vcenter",
"com.vmware.appliance",
"com.vmware.content",
],
("GET", "/api/vcenter/activity-history"): [
{
"activity": "activity-lab-1",
"description": "Seed inventory",
"status": "SUCCEEDED",
"start_time": "2026-01-01T00:00:00.000Z",
"user": "administrator@vsphere.local",
},
{
"activity": "activity-lab-2",
"description": "Appliance networking update",
"status": "SUCCEEDED",
"start_time": "2026-01-01T00:05:00.000Z",
"user": "administrator@vsphere.local",
},
],
("GET", "/api/vcenter/services"): [
{"service": "vsphere-ui", "state": "STARTED"},
{"service": "vpxd", "state": "STARTED"},
{"service": "vapi-endpoint", "state": "STARTED"},
{"service": "rhttpproxy", "state": "STARTED"},
],
("GET", "/api/vcenter/namespace-management/clusters"): [
{
"cluster": cid,
"cluster_name": "Cluster",
"config_status": "RUNNING",
"kubernetes_status": "READY",
}
for cid in cluster_ids
],
("GET", "/api/vcenter/namespace-management/supervisors/{supervisor}/summary"): {
"supervisor": "supervisor-1",
"name": "supervisor-lab",
"config_status": "RUNNING",
"kubernetes_status": "READY",
"status": "ENABLED",
"clusters": cluster_ids,
},
("GET", "/api/vcenter/namespace-management/supervisor-services"): [
{"supervisor_service": "service-lab-1", "name": "demo-operator", "state": "ACTIVATED"}
],
("GET", "/api/esx/settings/clusters/{cluster}/software"): {
"base_image": {"version": "8.0.2-0.0"},
"components": {},
"commit": "commit-lab-1",
"status": "COMPLIANT",
"clusters": cluster_ids,
},
("GET", "/api/esx/settings/hosts/{host}/software"): {
"base_image": {"version": "8.0.2-0.0"},
"status": "COMPLIANT",
"hosts": host_ids[:20],
},
("GET", "/api/vcenter/crypto-manager/kms/providers"): [
{"provider": "native-kms", "type": "NATIVE", "status": "READY", "health": "OK"}
],
("GET", "/api/vcenter/trusted-infrastructure/trust-authority-clusters"): [
{"cluster": cid, "state": "ENABLED"} for cid in cluster_ids
],
("GET", "/api/vcenter/storage/policies"): [
{"policy": "policy-default", "name": "vSAN Default Storage Policy"},
{"policy": "policy-thin", "name": "Thin provision"},
],
("GET", "/api/vcenter/guest/customization-specs"): [
{"name": "linux-lab", "description": "Linux lab", "os_type": "LINUX"},
{"name": "windows-lab", "description": "Windows lab", "os_type": "WINDOWS"},
],
("GET", "/api/appliance/access/ssh"): {"enabled": True},
("GET", "/api/appliance/access/dcui"): {"enabled": True},
("GET", "/api/appliance/access/shell"): {"enabled": True, "timeout": 300},
("GET", "/api/appliance/access/consolecli"): {"enabled": True},
("GET", "/api/appliance/services"): [
{"service": "vsphere-ui", "state": "STARTED", "description": "vSphere Client"},
{"service": "vpxd", "state": "STARTED", "description": "vCenter Server"},
{"service": "vapi-endpoint", "state": "STARTED", "description": "vAPI Endpoint"},
],
("GET", "/api/vcenter/identity/providers"): [
{"provider": "vsphere.local", "name": "vsphere.local", "type_id": "LocalOS"}
],
("GET", "/api/vcenter/certificate-management/vcenter/tls"): {
"cert": "-----BEGIN CERTIFICATE-----\nMIIBlab\n-----END CERTIFICATE-----",
"valid_from": "2026-01-01T00:00:00.000Z",
"valid_to": "2028-01-01T00:00:00.000Z",
"subject_dn": "CN=vcenter.lab.local",
},
}
# Drop Nones
extras = {k: v for k, v in extras.items() if v is not None}
inserted = 0
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute("DELETE FROM vsphere_api_state")
batch: list[tuple[str, str, str, str]] = []
seen: set[str] = set()
for (verb, path), _status in IMPLEMENTED.items():
if verb != "GET" or not path.startswith("/api/"):
continue
payload = extras.get((verb, path))
if payload is None:
payload = default_for_get_path(path)
payload = ensure_nonempty(payload, path)
if path in {
"/api/vcenter/namespace-management/clusters",
"/api/vcenter/namespace-management/clusters/{cluster}",
}:
payload = [
{
"cluster": cid,
"cluster_name": "Cluster",
"config_status": "RUNNING",
"status": "COMPLIANT",
"kubernetes_status": "READY",
}
for cid in cluster_ids
]
if path in {
"/api/esx/settings/clusters/{cluster}/software",
"/api/esx/settings/clusters/software",
}:
payload = {
"base_image": {"version": "8.0.2-0.0"},
"components": {},
"commit": "commit-lab-1",
"status": "COMPLIANT",
"clusters": cluster_ids,
}
if path == "/api/vcenter/namespace-management/supervisors/{supervisor}/summary":
payload = {
"supervisor": "supervisor-1",
"name": "supervisor-lab",
"config_status": "RUNNING",
"kubernetes_status": "READY",
"status": "ENABLED",
"clusters": cluster_ids,
}
key = state_key(verb, path)
batch.append((key, verb, path, json.dumps(payload)))
seen.add(key)
inserted += 1
for (verb, path), payload in extras.items():
key = state_key(verb, path)
if key in seen:
continue
batch.append((key, verb, path, json.dumps(payload)))
seen.add(key)
inserted += 1
# Template hardware fallbacks (live inventory overrides when VM exists).
hardware_seed = {
"/api/vcenter/vm/{vm}/hardware/cdrom": [
{
"cdrom": "3000",
"label": "CD/DVD drive 1",
"state": "CONNECTED",
"backing": {"type": "ISO_FILE", "iso_file": "[datastore1] ISO/ubuntu.iso"},
}
],
"/api/vcenter/vm/{vm}/hardware/floppy": [{"floppy": "8000", "state": "NOT_CONNECTED"}],
"/api/vcenter/vm/{vm}/hardware/serial": [{"port": "9000", "yield_on_poll": True}],
"/api/vcenter/vm/{vm}/hardware/adapter/scsi": [
{"adapter": "1000", "type": "LSILOGIC", "sharing": "NONE", "pci_slot_number": 16}
],
"/api/vcenter/vm/{vm}/hardware/adapter/sata": [
{"adapter": "15000", "bus": 0, "pci_slot_number": 33}
],
"/api/vcenter/vm/{vm}/hardware/adapter/nvme": [
{"adapter": "19000", "bus": 0, "pci_slot_number": 160}
],
"/api/vcenter/vm/{vm}/hardware/parallel": [{"port": "10000", "yield_on_poll": True}],
"/api/vcenter/vm/{vm}/hardware/boot": {
"type": "BIOS",
"delay": 0,
"retry": False,
"retry_delay": 10000,
"enter_setup_mode": False,
},
"/api/vcenter/vm/{vm}/hardware/boot/device": [
{"type": "CDROM"},
{"type": "DISK"},
{"type": "ETHERNET"},
],
"/api/vcenter/vm/{vm}/guest/local-filesystem": {
"filesystems": {"/": {"capacity": 21474836480, "free_space": 10737418240}}
},
"/api/vcenter/host/{host}/storage/storage-device": [
{
"device": "naa.lab1",
"display_name": "Local Disk",
"capacity": 1099511627776,
"ssd": False,
}
],
"/api/vcenter/host/{host}/networking": {
"dns": {"servers": ["8.8.8.8"], "domains": ["lab.local"]},
"routing": {"default_gateway": "192.168.1.1"},
},
"/api/vcenter/storage/policies/{policy}/vm": [
{"vm": vid, "vm_home": True, "disks": []} for vid in vm_ids[:10]
],
}
for path, payload in hardware_seed.items():
key = state_key("GET", path)
if key in seen:
continue
batch.append((key, "GET", path, json.dumps(payload)))
seen.add(key)
inserted += 1
await conn.executemany(_SEED_STATE_SQL, batch)
_ = ds_ids
return {"api_state_rows": inserted, "hosts": len(host_ids), "vms_sampled": len(vm_ids)}
async def new_id(prefix: str = "id") -> str:
return f"{prefix}-{secrets.token_hex(4)}"
+164
View File
@@ -0,0 +1,164 @@
"""Mutable vCenter appliance networking / timesync state (lab persistence)."""
from __future__ import annotations
from typing import Any
from app.db.pool import Database
from app.vsphere.domain import api_state
from app.vsphere.errors import invalid_argument
_NETWORKING_KEY = "/api/appliance/networking"
_TIMESYNC_KEY = "/api/appliance/timesync"
_DEFAULT_NETWORKING: dict[str, Any] = {
"hostname": "vcenter.lab.local",
"node_name": "vcenter.lab.local",
"default_gateway": "192.168.1.1",
"dns": {
"mode": "DHCP",
"servers": ["8.8.8.8", "1.1.1.1"],
"domains": ["lab.local"],
},
"interfaces": [
{
"name": "nic0",
"status": "up",
"mac": "00:50:56:aa:bb:cc",
"ipv4": {"address": "192.168.1.50", "prefix": 24, "configurable": True},
}
],
"proxy": {"enabled": False, "server": "", "port": 0, "username": ""},
"no_proxy": ["localhost", "127.0.0.1", ".lab.local"],
}
_DEFAULT_TIMESYNC: dict[str, Any] = {
"mode": "NTP",
"servers": ["time.lab.local"],
"current_time": "2026-01-01T00:00:00.000Z",
}
def _merge_networking(payload: Any) -> dict[str, Any]:
"""Overlay stored fields onto the full lab default shape."""
base = dict(_DEFAULT_NETWORKING)
if not isinstance(payload, dict):
return base
merged = {**base, **payload}
dns_base = dict(_DEFAULT_NETWORKING["dns"])
dns_stored = payload.get("dns") if isinstance(payload.get("dns"), dict) else {}
merged["dns"] = {**dns_base, **dns_stored}
if not merged.get("interfaces"):
merged["interfaces"] = list(_DEFAULT_NETWORKING["interfaces"])
return merged
async def get_networking(database: Database) -> dict[str, Any]:
payload = await api_state.get_payload_or_seed(database, "GET", _NETWORKING_KEY)
if not isinstance(payload, dict):
return {}
dns = payload.get("dns") if isinstance(payload.get("dns"), dict) else {}
if not list(dns.get("domains") or []) or not list(dns.get("servers") or []):
restored = await api_state.restore_seed_payload(database, "GET", _NETWORKING_KEY)
if isinstance(restored, dict):
await save_networking(database, restored)
return restored
return payload
async def save_networking(database: Database, networking: dict[str, Any]) -> None:
await api_state.put_payload(database, "GET", _NETWORKING_KEY, networking)
# Keep Automation API GET mirrors in sync for stub/matrix probes.
hostname = str(networking.get("hostname") or "vcenter.lab.local")
dns = networking.get("dns") or {}
await api_state.put_payload(
database, "GET", "/api/appliance/networking/dns/hostname", {"name": hostname}
)
await api_state.put_payload(
database,
"GET",
"/api/appliance/networking/dns/servers",
{"mode": dns.get("mode") or "DHCP", "servers": list(dns.get("servers") or [])},
)
await api_state.put_payload(
database,
"GET",
"/api/appliance/networking/dns/domains",
list(dns.get("domains") or []),
)
async def set_hostname(database: Database, name: str) -> str:
hostname = name.strip()
if not hostname:
raise invalid_argument("name is required")
networking = await get_networking(database)
networking["hostname"] = hostname
networking["node_name"] = hostname
await save_networking(database, networking)
return hostname
async def get_hostname(database: Database) -> str:
networking = await get_networking(database)
return str(networking.get("hostname") or "vcenter.lab.local")
async def set_dns_servers(
database: Database,
*,
mode: str | None = None,
servers: list[str] | None = None,
) -> dict[str, Any]:
networking = await get_networking(database)
dns = dict(networking.get("dns") or {})
if mode is not None:
dns["mode"] = mode
if servers is not None:
dns["servers"] = [str(s) for s in servers]
networking["dns"] = dns
await save_networking(database, networking)
return {"mode": dns.get("mode") or "DHCP", "servers": list(dns.get("servers") or [])}
async def set_dns_domains(database: Database, domains: list[str]) -> list[str]:
networking = await get_networking(database)
dns = dict(networking.get("dns") or {})
dns["domains"] = [str(d) for d in domains]
networking["dns"] = dns
await save_networking(database, networking)
return list(dns["domains"])
async def get_timesync(database: Database) -> dict[str, Any]:
payload = await api_state.get_payload_or_seed(database, "GET", _TIMESYNC_KEY)
return payload if isinstance(payload, dict) else {}
async def seed_appliance_state(database: Database) -> None:
"""Write appliance networking/timesync seed baselines into vsphere_api_state."""
networking = dict(_DEFAULT_NETWORKING)
await api_state.put_seed_payload(database, "GET", _NETWORKING_KEY, networking)
await api_state.put_seed_payload(database, "GET", _TIMESYNC_KEY, dict(_DEFAULT_TIMESYNC))
await save_networking(database, networking)
dns = networking.get("dns") or {}
await api_state.put_seed_payload(
database,
"GET",
"/api/appliance/networking/dns/hostname",
{"name": networking["hostname"]},
)
await api_state.put_seed_payload(
database,
"GET",
"/api/appliance/networking/dns/servers",
{"mode": dns.get("mode") or "DHCP", "servers": list(dns.get("servers") or [])},
)
await api_state.put_seed_payload(
database,
"GET",
"/api/appliance/networking/dns/domains",
list(dns.get("domains") or []),
)
+632
View File
@@ -0,0 +1,632 @@
"""Content library + datastore file metadata."""
from __future__ import annotations
import secrets
from typing import Any
from asyncpg.exceptions import UniqueViolationError # type: ignore[import-untyped]
from app.db.pool import Database
from app.vsphere import inventory
from app.vsphere.domain import tagging
from app.vsphere.domain import tasks as task_store
from app.vsphere.errors import already_exists, invalid_argument, not_found
def _pool(database: Database) -> Any:
return database.pool # type: ignore[attr-defined]
async def create_library(
database: Database,
*,
name: str,
description: str = "",
library_id: str | None = None,
) -> str:
if not name.strip():
raise invalid_argument("name is required")
lib_id = library_id or f"lib-{secrets.token_hex(6)}"
pool = _pool(database)
try:
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_libraries (id, name, description, type, props)
VALUES ($1, $2, $3, 'LOCAL', '{}'::jsonb)
ON CONFLICT (id) DO NOTHING
""",
lib_id,
name,
description,
)
# Id already present (seed ensure) — treat as success.
exists = await conn.fetchval("SELECT 1 FROM vsphere_libraries WHERE id = $1", lib_id)
if exists:
return lib_id
except UniqueViolationError as error:
# Name collision with a different id: keep requesting id with a unique name.
if library_id:
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_libraries (id, name, description, type, props)
VALUES ($1, $2, $3, 'LOCAL', '{}'::jsonb)
ON CONFLICT (id) DO NOTHING
""",
lib_id,
f"{name} ({lib_id})",
description,
)
return lib_id
raise already_exists(f"Library {name} already exists") from error
return lib_id
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
]
async def create_library_item(
database: Database,
*,
library_id: str,
name: str,
item_type: str = "ovf",
description: str = "",
item_id: str | None = None,
) -> str:
pool = _pool(database)
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT 1 FROM vsphere_libraries WHERE id = $1", library_id)
if not exists:
raise not_found(f"Library {library_id} not found")
resolved_id = item_id or f"item-{secrets.token_hex(6)}"
try:
await conn.execute(
"""
INSERT INTO vsphere_library_items (id, library_id, name, type, description)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO NOTHING
""",
resolved_id,
library_id,
name,
item_type,
description,
)
except UniqueViolationError:
# (library_id, name) taken — keep stable id with a unique name for seed.
if item_id:
await conn.execute(
"""
INSERT INTO vsphere_library_items (id, library_id, name, type, description)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO NOTHING
""",
resolved_id,
library_id,
f"{name}-{resolved_id}",
item_type,
description,
)
else:
raise
return resolved_id
async def list_library_items(database: Database, library_id: str) -> list[dict[str, Any]]:
pool = _pool(database)
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT 1 FROM vsphere_libraries WHERE id = $1", library_id)
if not exists:
raise not_found(f"Library {library_id} not found")
rows = await conn.fetch(
"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
]
_LAB_SESSION_ID = "session-lab-1"
_LAB_ITEM_ID = "item-ubuntu"
async def clear_transfer_sessions(database: Database) -> None:
"""Drop durable transfer sessions (used by inventory reseed)."""
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
"""
DO $$ BEGIN
IF to_regclass('public.vsphere_transfer_sessions') IS NOT NULL THEN
DELETE FROM vsphere_transfer_sessions;
END IF;
END $$;
"""
)
def _decode_files(value: Any) -> dict[str, Any]:
import json
current = value
while isinstance(current, str):
current = json.loads(current)
if isinstance(current, dict):
return current
return {}
async def _upsert_session(
database: Database,
*,
session_id: str,
kind: str,
library_item_id: str,
state: str,
files: dict[str, Any],
) -> None:
import json
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_transfer_sessions (id, kind, library_item_id, state, files, updated_at)
VALUES ($1, $2, $3, $4, $5::jsonb, now())
ON CONFLICT (id, kind) DO UPDATE SET
library_item_id = EXCLUDED.library_item_id,
state = EXCLUDED.state,
files = EXCLUDED.files,
updated_at = now()
""",
session_id,
kind,
library_item_id,
state,
json.dumps(files),
)
async def _get_session_row(
database: Database, session_id: str, kind: str | None = None
) -> dict[str, Any] | None:
pool = _pool(database)
async with pool.acquire() as conn:
if kind:
row = await conn.fetchrow(
"SELECT * FROM vsphere_transfer_sessions WHERE id = $1 AND kind = $2",
session_id,
kind,
)
else:
row = await conn.fetchrow(
"SELECT * FROM vsphere_transfer_sessions WHERE id = $1",
session_id,
)
if row is None:
return None
return {
"id": row["id"],
"kind": row["kind"],
"library_item_id": row["library_item_id"],
"state": row["state"],
"files": _decode_files(row["files"]),
}
async def create_update_session(
database: Database,
*,
library_item_id: str,
session_id: str | None = None,
) -> str:
pool = _pool(database)
async with pool.acquire() as conn:
exists = await conn.fetchval(
"SELECT 1 FROM vsphere_library_items WHERE id = $1", library_item_id
)
if not exists:
raise not_found(f"Library item {library_item_id} not found")
resolved = session_id or f"update-session-{secrets.token_hex(6)}"
files = {
"upload.bin": {
"name": "upload.bin",
"source_type": "PUSH",
"size": 1024,
"status": "READY",
"upload_endpoint": {
"uri": f"/api/content/library/item/update-session/{resolved}/file/upload.bin",
},
}
}
await _upsert_session(
database,
session_id=resolved,
kind="update",
library_item_id=library_item_id,
state="ACTIVE",
files=files,
)
return resolved
async def add_update_session_file(
database: Database,
session_id: str,
*,
name: str,
source_type: str = "PUSH",
size: int = 0,
content: str = "",
) -> dict[str, Any]:
session = await _get_session_row(database, session_id, "update")
if session is None:
raise not_found(f"Update session {session_id} not found")
file_info = {
"name": name,
"source_type": source_type,
"size": size or len(content.encode("utf-8")),
"status": "READY",
"upload_endpoint": {
"uri": f"/api/content/library/item/update-session/{session_id}/file/{name}",
},
"content": content,
}
files = dict(session["files"])
files[name] = file_info
await _upsert_session(
database,
session_id=session_id,
kind="update",
library_item_id=session["library_item_id"],
state=session["state"],
files=files,
)
return {
"name": name,
"source_type": source_type,
"size": file_info["size"],
"status": "READY",
"upload_endpoint": file_info["upload_endpoint"],
}
async def complete_update_session(database: Database, session_id: str) -> None:
session = await _get_session_row(database, session_id, "update")
if session is None:
raise not_found(f"Update session {session_id} not found")
await _upsert_session(
database,
session_id=session_id,
kind="update",
library_item_id=session["library_item_id"],
state="DONE",
files=session["files"],
)
async def get_update_session(database: Database, session_id: str) -> dict[str, Any]:
session = await _get_session_row(database, session_id, "update")
if session is None:
raise not_found(f"Update session {session_id} not found")
return {
"id": session["id"],
"library_item_id": session["library_item_id"],
"state": session["state"],
"client_progress": 100 if session["state"] == "DONE" else 50,
}
async def create_download_session(
database: Database,
*,
library_item_id: str,
session_id: str | None = None,
) -> str:
pool = _pool(database)
async with pool.acquire() as conn:
exists = await conn.fetchval(
"SELECT 1 FROM vsphere_library_items WHERE id = $1", library_item_id
)
if not exists:
raise not_found(f"Library item {library_item_id} not found")
resolved = session_id or f"download-session-{secrets.token_hex(6)}"
files = {
"descriptor.ovf": {
"name": "descriptor.ovf",
"size": 256,
"status": "READY",
"download_endpoint": {
"uri": f"/api/content/library/item/download-session/{resolved}/file/descriptor.ovf",
},
}
}
await _upsert_session(
database,
session_id=resolved,
kind="download",
library_item_id=library_item_id,
state="ACTIVE",
files=files,
)
return resolved
async def get_download_session(database: Database, session_id: str) -> dict[str, Any]:
session = await _get_session_row(database, session_id, "download")
if session is None:
raise not_found(f"Download session {session_id} not found")
return {
"id": session["id"],
"library_item_id": session["library_item_id"],
"state": session["state"],
}
async def list_download_session_files(database: Database, session_id: str) -> list[dict[str, Any]]:
session = await _get_session_row(database, session_id, "download")
if session is None:
raise not_found(f"Download session {session_id} not found")
return [
{
"name": f["name"],
"size": f["size"],
"status": f["status"],
"download_endpoint": f["download_endpoint"],
}
for f in session["files"].values()
]
async def deploy_ovf_from_library(
database: Database,
*,
item_id: str,
name: str,
folder: str = "group-v23",
host: str = "host-11",
datastore: str = "datastore-31",
) -> tuple[str, str]:
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")
moid = await inventory.next_moid(database, "vm")
await inventory.upsert_object(
database,
moid=moid,
type_name="VirtualMachine",
name=name,
parent_moid=folder,
props={
"power_state": "POWERED_OFF",
"cpu_count": 2,
"memory_size_mib": 2048,
"guest_OS": "OTHER_GUEST_64",
"hardware_version": "VMX_19",
"host": host,
"datastore": datastore,
"networks": ["network-41"],
"identity": {"name": name},
"deployed_from_library_item": item_id,
"nics": [],
"disks": [
{
"key": "2000",
"value": {"label": "Hard disk 1", "capacity": 21474836480, "type": "SCSI"},
}
],
},
)
task_id = await task_store.create_task(
database,
description=f"Deploy OVF item {item_id} as {moid}",
service="com.vmware.vcenter.ovf",
operation="deploy",
result={"vm": moid},
)
return moid, task_id
async def list_datastore_files(database: Database, datastore: str) -> list[dict[str, Any]]:
obj = await inventory.get_object(database, datastore)
if obj is None or obj.type != "Datastore":
raise not_found(f"Datastore {datastore} not found")
pool = _pool(database)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT path, size, type FROM vsphere_datastore_files
WHERE datastore_moid = $1 ORDER BY path
""",
datastore,
)
return [{"path": row["path"], "size": row["size"], "type": row["type"]} for row in rows]
async def put_datastore_file(
database: Database,
datastore: str,
path: str,
*,
size: int = 0,
file_type: str = "FILE",
) -> None:
obj = await inventory.get_object(database, datastore)
if obj is None or obj.type != "Datastore":
raise not_found(f"Datastore {datastore} not found")
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_datastore_files (datastore_moid, path, size, type)
VALUES ($1, $2, $3, $4)
ON CONFLICT (datastore_moid, path) DO UPDATE SET size = EXCLUDED.size, type = EXCLUDED.type
""",
datastore,
path,
size,
file_type,
)
async def seed_platform_extras(database: Database) -> 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``, …
"""
from app.vsphere.domain import tasks as task_store
# Ensure lab libraries/items even when older random-id rows already exist.
lib = await create_library(
database,
name="Local Content",
description="Lab library",
library_id="lib-local-1",
)
await create_library_item(
database,
library_id=lib,
name="ubuntu-22.04",
item_type="ovf",
description="Ubuntu template OVF",
item_id="item-ubuntu",
)
await create_library_item(
database,
library_id=lib,
name="centos-stream-9",
item_type="ovf",
description="CentOS Stream OVF",
item_id="item-centos",
)
pub = await create_library(
database,
name="Published Templates",
description="Published lab library",
library_id="lib-published-1",
)
await create_library_item(
database,
library_id=pub,
name="golden-image",
item_type="ovf",
description="Golden image OVF",
item_id="item-golden",
)
env = await tagging.create_category(
database,
name="Environment",
description="Environment tags",
associable_types=["VirtualMachine", "HostSystem"],
category_id="urn:vmomi:InventoryServiceCategory:environment:GLOBAL",
)
owner = await tagging.create_category(
database,
name="Owner",
description="Team ownership",
associable_types=["VirtualMachine"],
category_id="urn:vmomi:InventoryServiceCategory:owner:GLOBAL",
)
prod = await tagging.create_tag(
database,
category_id=env,
name="prod",
tag_id="urn:vmomi:InventoryServiceTag:prod:GLOBAL",
)
await tagging.create_tag(
database,
category_id=env,
name="staging",
tag_id="urn:vmomi:InventoryServiceTag:staging:GLOBAL",
)
await tagging.create_tag(
database,
category_id=owner,
name="platform",
tag_id="urn:vmomi:InventoryServiceTag:platform:GLOBAL",
)
await tagging.create_category(
database,
name="Lab",
description="Probe alias category",
associable_types=["VirtualMachine"],
category_id="cat-lab-1",
)
await tagging.create_tag(
database,
category_id="cat-lab-1",
name="lab",
tag_id="tag-lab-1",
)
await tagging.attach_tag(database, prod, "VirtualMachine", "vm-101")
await tagging.attach_tag(database, prod, "VirtualMachine", "vm-102")
await tagging.attach_tag(database, "tag-lab-1", "VirtualMachine", "vm-101")
await put_datastore_file(
database, "datastore-31", "[datastore1] ISO/ubuntu.iso", size=900000000
)
await put_datastore_file(
database, "datastore-31", "[datastore1] ISO/vmware-tools.iso", size=120000000
)
await put_datastore_file(database, "datastore-31", "[datastore1] web-01/web-01.vmx", size=4096)
await put_datastore_file(
database, "datastore-31", "[datastore1] web-01/web-01.vmdk", size=42949672960
)
item_id = _LAB_ITEM_ID
pool = _pool(database)
async with pool.acquire() as conn:
has_item = await conn.fetchval("SELECT 1 FROM vsphere_library_items WHERE id = $1", item_id)
if has_item:
await create_download_session(database, library_item_id=item_id, session_id=_LAB_SESSION_ID)
await create_update_session(database, library_item_id=item_id, session_id=_LAB_SESSION_ID)
# Lab snapshots so GET /snapshots is DB-backed (no create-on-read).
from app.vsphere.domain import vm_ops
for vm_moid in ("vm-101", "vm-102"):
snaps = await vm_ops.list_snapshots(database, vm_moid)
if not snaps:
await vm_ops.create_snapshot(
database, vm_moid, name="initial", description="Lab default snapshot"
)
await task_store.create_task(
database,
description="Lab inventory seed",
service="com.vmware.vcenter",
operation="seed",
status="SUCCEEDED",
result={"seeded": True, "profile": "lab"},
task_id="task-1",
)
+225
View File
@@ -0,0 +1,225 @@
"""CRUD for folders, datacenters, clusters, resource pools, hosts, DVS."""
from __future__ import annotations
from typing import Any
from app.db.pool import Database
from app.vsphere import inventory
from app.vsphere.errors import invalid_argument, not_found
async def create_folder(
database: Database,
*,
name: str,
parent: str,
folder_type: str = "VIRTUAL_MACHINE",
) -> str:
parent_obj = await inventory.get_object(database, parent)
if parent_obj is None:
raise not_found(f"Parent {parent} not found")
moid = await inventory.next_moid(database, "group")
# next_moid with group- may not work well; use folder- style
import secrets
moid = f"group-{secrets.token_hex(3)}"
await inventory.upsert_object(
database,
moid=moid,
type_name="Folder",
name=name,
parent_moid=parent,
props={"folder_type": folder_type},
)
return moid
async def create_datacenter(database: Database, *, name: str, folder: str = "group-d1") -> str:
import secrets
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}"
ds_folder = f"group-s{secrets.randbelow(90) + 10}"
net_folder = f"group-n{secrets.randbelow(90) + 10}"
await inventory.upsert_object(
database,
moid=moid,
type_name="Datacenter",
name=name,
parent_moid=folder,
props={
"datastore_folder": ds_folder,
"host_folder": host_folder,
"vm_folder": vm_folder,
"network_folder": net_folder,
},
)
for child_moid, child_name, ftype in (
(host_folder, "host", "HOST"),
(vm_folder, "vm", "VIRTUAL_MACHINE"),
(ds_folder, "datastore", "DATASTORE"),
(net_folder, "network", "NETWORK"),
):
await inventory.upsert_object(
database,
moid=child_moid,
type_name="Folder",
name=child_name,
parent_moid=moid,
props={"folder_type": ftype},
)
return moid
async def create_cluster(
database: Database,
*,
name: str,
folder: str = "group-h23",
drs_enabled: bool = True,
ha_enabled: bool = True,
) -> str:
import secrets
moid = f"domain-c{secrets.randbelow(900) + 100}"
rp = f"resgroup-{secrets.randbelow(900) + 100}"
await inventory.upsert_object(
database,
moid=moid,
type_name="ClusterComputeResource",
name=name,
parent_moid=folder,
props={"drs_enabled": drs_enabled, "ha_enabled": ha_enabled, "resource_pool": rp},
)
await inventory.upsert_object(
database,
moid=rp,
type_name="ResourcePool",
name="Resources",
parent_moid=moid,
props={"cpu_limit_mhz": -1, "memory_limit_mib": -1},
)
return moid
async def create_resource_pool(
database: Database,
*,
name: str,
parent: str,
) -> str:
parent_obj = await inventory.get_object(database, parent)
if parent_obj is None:
raise not_found(f"Parent {parent} not found")
import secrets
moid = f"resgroup-{secrets.randbelow(900) + 100}"
await inventory.upsert_object(
database,
moid=moid,
type_name="ResourcePool",
name=name,
parent_moid=parent,
props={"cpu_limit_mhz": -1, "memory_limit_mib": -1},
)
return moid
async def rename_object(database: Database, moid: str, name: str) -> None:
obj = await inventory.get_object(database, moid)
if obj is None:
raise not_found(f"Object {moid} not found")
await inventory.upsert_object(
database,
moid=obj.moid,
type_name=obj.type,
name=name,
parent_moid=obj.parent_moid,
props=obj.props,
)
async def move_object(database: Database, moid: str, parent: str) -> None:
obj = await inventory.get_object(database, moid)
if obj is None:
raise not_found(f"Object {moid} not found")
parent_obj = await inventory.get_object(database, parent)
if parent_obj is None:
raise not_found(f"Parent {parent} not found")
await inventory.upsert_object(
database,
moid=obj.moid,
type_name=obj.type,
name=obj.name,
parent_moid=parent,
props=obj.props,
)
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")
children = [
child for child in await inventory.list_objects(database) if child.parent_moid == moid
]
if children:
raise invalid_argument(f"Object {moid} still has children")
await inventory.delete_object(database, moid)
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":
raise not_found(f"Host {host} not found")
props = dict(obj.props)
props["connection_state"] = "CONNECTED"
props["maintenance_mode"] = enabled
await inventory.update_props(database, host, props)
return {"host": host, "maintenance_mode": enabled}
async def create_dvs(
database: Database,
*,
name: str,
folder: str = "group-n23",
) -> str:
import secrets
moid = f"dvs-{secrets.randbelow(900) + 100}"
await inventory.upsert_object(
database,
moid=moid,
type_name="VmwareDistributedVirtualSwitch",
name=name,
parent_moid=folder,
props={"version": "8.0.0", "num_ports": 128},
)
return moid
async def create_dvpg(
database: Database,
*,
name: str,
dvs: str,
vlan_id: int = 0,
) -> str:
parent = await inventory.get_object(database, dvs)
if parent is None:
raise not_found(f"DVS {dvs} not found")
import secrets
moid = f"dvportgroup-{secrets.randbelow(900) + 100}"
await inventory.upsert_object(
database,
moid=moid,
type_name="DistributedVirtualPortgroup",
name=name,
parent_moid=dvs,
props={"type": "DISTRIBUTED_PORTGROUP", "vlan_id": vlan_id, "dvs": dvs},
)
return moid
+434
View File
@@ -0,0 +1,434 @@
"""Lab-grade NSX / WCP / vSAN / identity / VECS / NFC state (DB-backed)."""
from __future__ import annotations
import json
import secrets
from typing import Any
from app.db.pool import Database
from app.vsphere import inventory
from app.vsphere.domain import api_state
_IDENTITY = "/api/vcenter/identity/providers"
def default_identity_providers() -> list[dict[str, Any]]:
return [
{
"provider": "vsphere.local",
"name": "vsphere.local",
"type_id": "LocalOS",
"domain_names": ["vsphere.local"],
"is_default": True,
},
{
"provider": "oidc-lab",
"name": "Lab OIDC",
"type_id": "Oidc",
"issuer_uri": "https://idp.lab.local/realms/vsphere",
"client_id": "vcenter-lab",
"is_default": False,
},
{
"provider": "saml-lab",
"name": "Lab SAML",
"type_id": "Saml",
"idp_entity_id": "https://idp.lab.local/saml",
"sso_service_url": "https://idp.lab.local/saml/sso",
"is_default": False,
},
]
def default_nsx_surface() -> dict[str, Any]:
return {
("GET", "/api/vcenter/namespace-management/nsx-tier0-gateway"): [
{
"gateway": "nsx-tier0-1",
"name": "tier0-lab",
"path": "/infra/tier-0s/tier0-lab",
"status": "UP",
}
],
("GET", "/api/vcenter/namespace-management/networks/{network}/nsx/projects"): [
{
"project": "nsx-project-1",
"name": "default",
"path": "/orgs/default/projects/default",
"status": "READY",
}
],
("GET", "/api/vcenter/namespace-management/networks/{network}/nsx/edges"): [
{
"edge": "nsx-edge-1",
"name": "edge-lab-1",
"path": "/infra/sites/default/enforcement-points/default/edge-clusters/edge-1",
"status": "UP",
}
],
(
"GET",
"/api/vcenter/namespace-management/networks/{network}/nsx/distributed-switches",
): [
{
"distributed_switch": "nsx-dvs-1",
"name": "NSX-DVS",
"path": "/infra/sites/default/enforcement-points/default/transport-zones/tz-1",
"status": "UP",
}
],
("GET", "/api/vcenter/namespace-management/networks/{network}/nsx/vpcs"): [
{
"vpc": "vpc-lab-1",
"name": "vpc-lab",
"path": "/orgs/default/projects/default/vpcs/vpc-lab",
"status": "READY",
}
],
(
"GET",
"/api/vcenter/namespace-management/networks/{network}/nsx/vpc-connectivity-profiles",
): [
{
"profile": "vpc-profile-1",
"name": "default-connectivity",
"status": "READY",
}
],
(
"GET",
"/api/vcenter/namespaces/{namespace}/networks/{network}/nsx/subnets",
): [
{
"subnet": "subnet-lab-1",
"name": "workload",
"cidr": "10.244.0.0/24",
"status": "READY",
}
],
}
def default_wcp_surface() -> dict[str, Any]:
return {
("GET", "/api/vcenter/namespace-management/networks"): [
{
"network": "network-41",
"name": "VM Network",
"mode": "NSXT_VPC",
"status": "READY",
}
],
("GET", "/api/vcenter/namespaces"): [
{
"namespace": "ns-lab-1",
"name": "ns-lab-1",
"cluster": "domain-c21",
"config_status": "RUNNING",
"stats": {"cpu_used": 2, "memory_used_mib": 4096},
}
],
("GET", "/api/vcenter/namespace-management/virtual-machine-classes"): [
{
"id": "best-effort-small",
"cpu_count": 2,
"memory_mb": 2048,
"description": "Lab small class",
},
{
"id": "guaranteed-large",
"cpu_count": 8,
"memory_mb": 16384,
"description": "Lab large class",
},
],
("GET", "/api/vcenter/namespace-management/clusters/{cluster}/nsm"): {
"cluster": "domain-c21",
"config_status": "RUNNING",
"kubernetes_status": "READY",
"network_provider": "NSXT_CONTAINER_PLUGIN",
},
(
"GET",
"/api/vcenter/namespace-management/supervisors/{supervisor}/identity/providers",
): [
{
"provider": "oidc-supervisor",
"name": "Supervisor OIDC",
"issuer": "https://idp.lab.local/realms/supervisor",
"type": "OIDC",
"status": "READY",
}
],
("GET", "/api/vcenter/namespace-management/infrastructure-policies"): [
{
"policy": "infra-policy-lab-1",
"name": "default-infra",
"status": "ENABLED",
}
],
}
def default_vsan_surface() -> dict[str, Any]:
return {
("GET", "/api/vcenter/storage/policies"): [
{
"policy": "policy-default",
"name": "vSAN Default Storage Policy",
"description": "Lab vSAN default",
"policy_type": "VSAN",
},
{
"policy": "policy-thin",
"name": "Thin provision",
"description": "Thin disks",
"policy_type": "VVOL",
},
{
"policy": "policy-vsan-raid1",
"name": "vSAN RAID1",
"description": "Failures to tolerate = 1",
"policy_type": "VSAN",
},
],
("GET", "/api/vcenter/storage/policies/{policy}"): {
"policy": "policy-default",
"name": "vSAN Default Storage Policy",
"description": "Lab vSAN default",
"policy_type": "VSAN",
"constraints": [{"property_name": "hostFailuresToTolerate", "value": 1}],
},
}
def default_vecs_surface() -> dict[str, Any]:
return {
("GET", "/api/vcenter/certificate-management/vcenter/tls-csr"): {
"csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBLabCSR\n-----END CERTIFICATE REQUEST-----",
"status": "AVAILABLE",
"subject_dn": "CN=vcenter.lab.local",
},
("GET", "/api/vcenter/certificate-management/vcenter/signing-certificate"): {
"cert": "-----BEGIN CERTIFICATE-----\nMIIBSigning\n-----END CERTIFICATE-----",
"valid_from": "2026-01-01T00:00:00.000Z",
"valid_to": "2030-01-01T00:00:00.000Z",
"subject_dn": "CN=CA,DC=vsphere,DC=local",
},
("GET", "/api/vcenter/certificate-management/vcenter/trusted-root-chains"): [
{
"chain": "chain-lab-1",
"cert_chain": ["-----BEGIN CERTIFICATE-----\nMIIBRoot\n-----END CERTIFICATE-----"],
"thumbprint": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD",
}
],
(
"GET",
"/api/vcenter/namespace-management/supervisors/{supervisor}/certificates",
): [
{
"certificate": "supervisor-tls",
"status": "VALID",
"valid_to": "2028-01-01T00:00:00.000Z",
}
],
(
"GET",
"/api/vcenter/namespace-management/supervisors/{supervisor}/signing-requests",
): [
{
"request": "csr-supervisor-1",
"status": "PENDING",
"csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBSupCSR\n-----END CERTIFICATE REQUEST-----",
}
],
}
async def seed_platform_surface(database: Database) -> dict[str, int]:
"""Overlay rich lab payloads for previously shallow product areas (seed-time only)."""
rows = 0
providers = default_identity_providers()
await api_state.put_seed_payload(database, "GET", _IDENTITY, providers)
rows += 1
for mapping in (
default_nsx_surface(),
default_wcp_surface(),
default_vsan_surface(),
default_vecs_surface(),
):
for (verb, path), payload in mapping.items():
await api_state.put_seed_payload(database, verb, path, payload)
rows += 1
return {"platform_surface_rows": rows}
async def list_identity_providers(database: Database) -> list[dict[str, Any]]:
payload = await api_state.get_payload_or_seed(database, "GET", _IDENTITY)
if not isinstance(payload, list) or not payload:
return []
if not any(str(item.get("provider")) == "vsphere.local" for item in payload):
restored = await api_state.restore_seed_payload(database, "GET", _IDENTITY)
if isinstance(restored, list) and restored:
return restored
return payload
async def get_identity_provider(database: Database, provider: str) -> dict[str, Any] | None:
for item in await list_identity_providers(database):
if provider in {
str(item.get("provider")),
str(item.get("name")),
str(item.get("id")),
}:
return item
return None
async def upsert_identity_provider(database: Database, body: dict[str, Any]) -> dict[str, Any]:
providers = await list_identity_providers(database)
provider_id = str(body.get("provider") or body.get("name") or f"idp-{secrets.token_hex(3)}")
entry = {
"provider": provider_id,
"name": str(body.get("name") or provider_id),
"type_id": str(body.get("type_id") or body.get("type") or "Oidc"),
"is_default": bool(body.get("is_default", False)),
**{
k: v
for k, v in body.items()
if k not in {"provider", "name", "type_id", "type", "is_default"}
},
}
out: list[dict[str, Any]] = []
replaced = False
for item in providers:
if str(item.get("provider")) == provider_id:
out.append({**item, **entry})
replaced = True
else:
out.append(item)
if not replaced:
out.append(entry)
await api_state.put_payload(database, "GET", _IDENTITY, out)
return entry
async def delete_identity_provider(database: Database, provider: str) -> bool:
providers = await list_identity_providers(database)
filtered = [p for p in providers if str(p.get("provider")) != provider]
if len(filtered) == len(providers):
return False
# Soft-delete lab baseline: restore seed document so GET stays non-empty.
await api_state.restore_seed_payload(database, "GET", _IDENTITY)
return True
def _decode_payload(value: Any) -> dict[str, Any]:
current = value
while isinstance(current, str):
current = json.loads(current)
return current if isinstance(current, dict) else {}
async def _save_nfc_lease(database: Database, lease_id: str, info: dict[str, Any]) -> None:
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_nfc_leases (id, vm_moid, state, payload, updated_at)
VALUES ($1, $2, $3, $4::jsonb, now())
ON CONFLICT (id) DO UPDATE SET
vm_moid = EXCLUDED.vm_moid,
state = EXCLUDED.state,
payload = EXCLUDED.payload,
updated_at = now()
""",
lease_id,
str(info.get("entity") or ""),
str(info.get("state") or "ready"),
json.dumps(info),
)
await inventory.upsert_object(
database,
moid=lease_id,
type_name="HttpNfcLease",
name=lease_id,
parent_moid=None,
props=info,
)
async def create_nfc_lease(
database: Database,
*,
vm: str,
files: list[str] | None = None,
) -> dict[str, Any]:
lease_id = f"lease-{secrets.token_hex(6)}"
file_list = files or [f"{vm}.vmdk", f"{vm}.nvram"]
info = {
"lease": lease_id,
"state": "ready",
"entity": vm,
"initializeProgress": 100,
"transferProgress": 0,
"info": {
"deviceUrl": [
{
"key": f"disk-{index}",
"importKey": name,
"url": f"https://localhost/nfc/{lease_id}/files/{name}",
"sslThumbprint": "https://example.invalid/thumbprint",
}
for index, name in enumerate(file_list)
]
},
"files": {name: {"uploaded": False, "size": 0} for name in file_list},
}
await _save_nfc_lease(database, lease_id, info)
return info
async def get_nfc_lease(database: Database, lease_id: str) -> dict[str, Any] | None:
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT payload FROM vsphere_nfc_leases WHERE id = $1",
lease_id,
)
if row is None:
return None
return _decode_payload(row["payload"])
async def complete_nfc_lease(database: Database, lease_id: str) -> dict[str, Any] | None:
lease = await get_nfc_lease(database, lease_id)
if lease is None:
return None
lease["state"] = "done"
lease["transferProgress"] = 100
await _save_nfc_lease(database, lease_id, lease)
return lease
async def upload_nfc_file(
database: Database,
lease_id: str,
filename: str,
size: int,
) -> dict[str, Any] | None:
lease = await get_nfc_lease(database, lease_id)
if lease is None:
return None
files = lease.setdefault("files", {})
files[filename] = {"uploaded": True, "size": size}
uploaded = sum(1 for meta in files.values() if meta.get("uploaded"))
total = max(len(files), 1)
lease["transferProgress"] = int(100 * uploaded / total)
if uploaded >= total:
lease["state"] = "done"
await _save_nfc_lease(database, lease_id, lease)
return lease
+321
View File
@@ -0,0 +1,321 @@
"""CIS tagging store."""
from __future__ import annotations
import secrets
from typing import Any
from app.db.pool import Database
from app.vsphere.errors import already_exists, not_found
def _pool(database: Database) -> Any:
return database.pool # type: ignore[attr-defined]
async def create_category(
database: Database,
*,
name: str,
description: str = "",
cardinality: str = "MULTIPLE",
associable_types: list[str] | None = None,
category_id: str | None = None,
) -> str:
cat_id = category_id or f"urn:vmomi:InventoryServiceCategory:{secrets.token_hex(8)}:GLOBAL"
types = associable_types or []
pool = _pool(database)
async with pool.acquire() as conn:
existing = await conn.fetchval("SELECT 1 FROM vsphere_tag_categories WHERE id = $1", cat_id)
if existing:
return cat_id
try:
await conn.execute(
"""
INSERT INTO vsphere_tag_categories (id, name, description, cardinality, associable_types)
VALUES ($1, $2, $3, $4, $5)
""",
cat_id,
name,
description,
cardinality,
types,
)
except Exception as error:
if "unique" not in str(error).lower():
raise
if not category_id:
raise already_exists(f"Category {name} already exists") from error
# Name taken by another id — keep the requested stable id.
await conn.execute(
"""
INSERT INTO vsphere_tag_categories (id, name, description, cardinality, associable_types)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO NOTHING
""",
cat_id,
f"{name}-{cat_id[-12:]}",
description,
cardinality,
types,
)
return cat_id
async def list_categories(database: Database) -> list[str]:
pool = _pool(database)
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT id FROM vsphere_tag_categories ORDER BY name")
return [str(row["id"]) for row in rows]
def _cis_id(raw: str) -> str:
"""Strip legacy CIS ``id:`` URL prefix used by govmomi/terraform clients."""
value = raw.strip()
if value.startswith("id:"):
return value[3:]
return value
async def get_category(database: Database, category_id: str) -> dict[str, Any]:
category_id = _cis_id(category_id)
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM vsphere_tag_categories WHERE id = $1",
category_id,
)
if row is None:
raise not_found(f"Category {category_id} not found")
return {
"id": row["id"],
"name": row["name"],
"description": row["description"],
"cardinality": row["cardinality"],
"associable_types": list(row["associable_types"] or []),
}
_LAB_CATEGORIES: dict[str, dict[str, Any]] = {
"cat-lab-1": {
"name": "Lab",
"description": "Probe alias category",
"associable_types": ["VirtualMachine"],
},
"urn:vmomi:InventoryServiceCategory:environment:GLOBAL": {
"name": "Environment",
"description": "Environment tags",
"associable_types": ["VirtualMachine", "HostSystem"],
},
"urn:vmomi:InventoryServiceCategory:owner:GLOBAL": {
"name": "Owner",
"description": "Team ownership",
"associable_types": ["VirtualMachine"],
},
}
_LAB_TAGS: dict[str, dict[str, str]] = {
"tag-lab-1": {"category_id": "cat-lab-1", "name": "lab", "description": ""},
"urn:vmomi:InventoryServiceTag:prod:GLOBAL": {
"category_id": "urn:vmomi:InventoryServiceCategory:environment:GLOBAL",
"name": "prod",
"description": "",
},
"urn:vmomi:InventoryServiceTag:staging:GLOBAL": {
"category_id": "urn:vmomi:InventoryServiceCategory:environment:GLOBAL",
"name": "staging",
"description": "",
},
"urn:vmomi:InventoryServiceTag:platform:GLOBAL": {
"category_id": "urn:vmomi:InventoryServiceCategory:owner:GLOBAL",
"name": "platform",
"description": "",
},
}
async def delete_category(database: Database, category_id: str) -> None:
category_id = _cis_id(category_id)
pool = _pool(database)
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM vsphere_tag_categories WHERE id = $1",
category_id,
)
if not result.endswith("1"):
raise not_found(f"Category {category_id} not found")
# Keep seeded lab categories durable for GET probes after DELETE.
lab = _LAB_CATEGORIES.get(category_id)
if lab is not None:
await create_category(database, category_id=category_id, **lab)
for tag_id, tag in _LAB_TAGS.items():
if tag["category_id"] == category_id:
await create_tag(
database,
category_id=category_id,
name=str(tag["name"]),
description=str(tag.get("description") or ""),
tag_id=tag_id,
)
async def create_tag(
database: Database,
*,
category_id: str,
name: str,
description: str = "",
tag_id: str | None = None,
) -> str:
await get_category(database, category_id)
resolved = tag_id or f"urn:vmomi:InventoryServiceTag:{secrets.token_hex(8)}:GLOBAL"
pool = _pool(database)
async with pool.acquire() as conn:
existing = await conn.fetchval("SELECT 1 FROM vsphere_tags WHERE id = $1", resolved)
if existing:
return resolved
try:
await conn.execute(
"""
INSERT INTO vsphere_tags (id, category_id, name, description)
VALUES ($1, $2, $3, $4)
""",
resolved,
category_id,
name,
description,
)
except Exception as error:
if "unique" not in str(error).lower():
raise
if not tag_id:
raise already_exists(f"Tag {name} already exists") from error
await conn.execute(
"""
INSERT INTO vsphere_tags (id, category_id, name, description)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO NOTHING
""",
resolved,
category_id,
f"{name}-{resolved[-12:]}",
description,
)
return resolved
async def list_tags(database: Database) -> list[str]:
pool = _pool(database)
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT id FROM vsphere_tags ORDER BY name")
return [str(row["id"]) for row in rows]
async def get_tag(database: Database, tag_id: str) -> dict[str, Any]:
tag_id = _cis_id(tag_id)
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM vsphere_tags WHERE id = $1", tag_id)
if row is None:
raise not_found(f"Tag {tag_id} not found")
return {
"id": row["id"],
"category_id": row["category_id"],
"name": row["name"],
"description": row["description"],
}
async def delete_tag(database: Database, tag_id: str) -> None:
tag_id = _cis_id(tag_id)
pool = _pool(database)
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM vsphere_tags WHERE id = $1", tag_id)
if not result.endswith("1"):
raise not_found(f"Tag {tag_id} not found")
lab = _LAB_TAGS.get(tag_id)
if lab is not None:
await create_tag(
database,
category_id=lab["category_id"],
name=lab["name"],
description=lab.get("description") or "",
tag_id=tag_id,
)
async def attach_tag(
database: Database,
tag_id: str,
object_type: str,
object_id: str,
) -> None:
await get_tag(database, tag_id)
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_tag_associations (tag_id, object_type, object_id)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
""",
tag_id,
object_type,
object_id,
)
async def detach_tag(
database: Database,
tag_id: str,
object_type: str,
object_id: str,
) -> None:
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
"""
DELETE FROM vsphere_tag_associations
WHERE tag_id = $1 AND object_type = $2 AND object_id = $3
""",
tag_id,
object_type,
object_id,
)
async def list_attached_tags(
database: Database,
object_type: str,
object_id: str,
) -> list[str]:
pool = _pool(database)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT tag_id FROM vsphere_tag_associations
WHERE object_type = $1 AND object_id = $2
ORDER BY tag_id
""",
object_type,
object_id,
)
return [str(row["tag_id"]) for row in rows]
async def list_attached_objects(
database: Database,
tag_id: str,
) -> list[dict[str, str]]:
pool = _pool(database)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT object_type, object_id FROM vsphere_tag_associations
WHERE tag_id = $1
ORDER BY object_type, object_id
""",
tag_id,
)
return [{"type": row["object_type"], "id": row["object_id"]} for row in rows]
+103
View File
@@ -0,0 +1,103 @@
"""CIS-style asynchronous tasks shared by REST and SOAP."""
from __future__ import annotations
import json
import secrets
from datetime import UTC, datetime
from typing import Any
from app.db.pool import Database
def _pool(database: Database) -> Any:
return database.pool # type: ignore[attr-defined]
async def create_task(
database: Database,
*,
description: str,
service: str,
operation: str,
status: str = "SUCCEEDED",
result: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
task_id: str | None = None,
) -> str:
resolved_id = task_id or f"task-{secrets.token_hex(8)}"
now = datetime.now(UTC)
completed = now if status in {"SUCCEEDED", "FAILED"} else None
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_tasks
(id, description, status, service, operation, result, error, completed_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8)
ON CONFLICT (id) DO UPDATE SET
description = EXCLUDED.description,
status = EXCLUDED.status,
service = EXCLUDED.service,
operation = EXCLUDED.operation,
result = EXCLUDED.result,
error = EXCLUDED.error,
completed_at = EXCLUDED.completed_at
""",
resolved_id,
description,
status,
service,
operation,
json.dumps(result) if result is not None else None,
json.dumps(error) if error is not None else None,
completed,
)
return resolved_id
async def get_task(database: Database, task_id: str) -> dict[str, Any] | None:
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM vsphere_tasks WHERE id = $1", task_id)
if row is None:
return None
return _row(row)
async def list_tasks(database: Database) -> list[dict[str, Any]]:
pool = _pool(database)
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT * FROM vsphere_tasks ORDER BY created_at DESC LIMIT 200")
return [_row(row) for row in rows]
def _row(row: Any) -> dict[str, Any]:
result = row["result"]
error = row["error"]
if isinstance(result, str):
result = json.loads(result)
if isinstance(error, str):
error = json.loads(error)
status = row["status"]
state = {
"PENDING": "PENDING",
"RUNNING": "RUNNING",
"SUCCEEDED": "SUCCEEDED",
"FAILED": "FAILED",
}.get(status, status)
return {
"task": row["id"],
"description": row["description"],
"status": status,
"state": state,
"service": row["service"],
"operation": row["operation"],
"progress": 100 if status in {"SUCCEEDED", "FAILED"} else 50,
"result": result,
"error": error,
"start_time": row["created_at"].isoformat() if row["created_at"] else None,
"end_time": row["completed_at"].isoformat() if row["completed_at"] else None,
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
}
+511
View File
@@ -0,0 +1,511 @@
"""VM lifecycle operations used by REST and SOAP."""
from __future__ import annotations
import copy
import json
import secrets
from typing import Any
from app.db.pool import Database
from app.vsphere import inventory
from app.vsphere.domain import tasks as task_store
from app.vsphere.errors import invalid_argument, not_found
async def require_vm(database: Database, vm: str) -> inventory.ManagedObject:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
return obj
def _default_identity(name: str, moid: str) -> dict[str, str]:
digits = "".join(ch for ch in moid if ch.isdigit()) or "0"
n = int(digits) % 10_000_000_000_000
return {
"name": name,
"instance_uuid": f"5029aaaa-bbbb-cccc-dddd-{n:012d}",
"bios_uuid": f"4200aaaa-bbbb-cccc-dddd-{n:012d}",
}
def _default_disk(capacity: int = 42949672960) -> dict[str, Any]:
return {
"key": "2000",
"value": {"label": "Hard disk 1", "capacity": capacity, "type": "SCSI"},
}
def _default_nic(network: str = "network-41", *, mac_tail: str = "01") -> dict[str, Any]:
return {
"key": "4000",
"value": {
"label": "Network adapter 1",
"mac": f"00:50:56:01:00:{mac_tail}",
"state": "NOT_CONNECTED",
"type": "VMXNET3",
"backing": {"type": "STANDARD_PORTGROUP", "network": network},
},
}
async def create_vm(
database: Database,
*,
name: str,
folder: str = "group-v23",
host: str = "host-11",
datastore: str = "datastore-31",
resource_pool: str = "resgroup-22",
guest_os: str = "OTHER_GUEST_64",
cpu_count: int = 1,
memory_size_mib: int = 1024,
networks: list[str] | None = None,
disks: list[dict[str, Any]] | None = None,
nics: list[dict[str, Any]] | None = None,
template: bool = False,
power_on: bool = False,
) -> tuple[str, str]:
"""Create a VirtualMachine MO and a CIS/vim task. Returns (moid, task_id)."""
name = name.strip()
if not name:
raise invalid_argument("name is required")
nets = networks or ["network-41"]
moid = await inventory.next_moid(database, "vm")
mac_tail = f"{int(''.join(ch for ch in moid if ch.isdigit()) or '1') % 256:02x}"
props: dict[str, Any] = {
"power_state": "POWERED_ON" if power_on else "POWERED_OFF",
"cpu_count": cpu_count,
"memory_size_mib": memory_size_mib,
"guest_OS": guest_os,
"hardware_version": "VMX_19",
"host": host,
"datastore": datastore,
"resource_pool": resource_pool,
"networks": nets,
"template": template,
"tools_status": "GUEST_TOOLS_NOT_RUNNING",
"identity": _default_identity(name, moid),
"disks": disks if disks is not None else [_default_disk()],
"nics": nics if nics is not None else [_default_nic(nets[0], mac_tail=mac_tail)],
"cdroms": [],
"scsi_adapters": [{"adapter": "1000", "type": "LSILOGIC", "sharing": "NONE"}],
"boot": {"type": "BIOS", "delay": 0},
"guest_filesystems": {
"filesystems": {"/": {"capacity": 42949672960, "free_space": 21474836480}}
},
"guest_files": {},
}
await inventory.upsert_object(
database,
moid=moid,
type_name="VirtualMachine",
name=name,
parent_moid=folder,
props=props,
)
task_id = await task_store.create_task(
database,
description=f"CreateVM {moid}",
service="vim.Folder",
operation="create_vm",
result={"vm": moid},
)
return moid, task_id
async def set_power(database: Database, vm: str, action: str) -> str:
obj = await require_vm(database, vm)
props = dict(obj.props)
current = props.get("power_state", "POWERED_OFF")
mapping = {
"start": "POWERED_ON",
"stop": "POWERED_OFF",
"suspend": "SUSPENDED",
"reset": "POWERED_ON",
}
if action not in mapping:
raise invalid_argument(f"unsupported power action: {action}")
if action == "start" and current == "POWERED_ON":
raise invalid_argument("VM is already powered on")
if action == "stop" and current == "POWERED_OFF":
raise invalid_argument("VM is already powered off")
props["power_state"] = mapping[action]
await inventory.update_props(database, vm, props)
return await task_store.create_task(
database,
description=f"Power {action} {vm}",
service="com.vmware.vcenter.vm.power",
operation=action,
result={"vm": vm, "power_state": props["power_state"]},
)
async def clone_vm(
database: Database,
*,
source_vm: str,
name: str,
folder: str | None = None,
host: str | None = None,
datastore: str | None = None,
resource_pool: str | None = None,
power_on: bool = False,
) -> tuple[str, str]:
src = await require_vm(database, source_vm)
moid = await inventory.next_moid(database, "vm")
props = copy.deepcopy(src.props)
props["power_state"] = "POWERED_ON" if power_on else "POWERED_OFF"
props["template"] = False
props["identity"] = _default_identity(name, moid)
if host:
props["host"] = host
if datastore:
props["datastore"] = datastore
if resource_pool:
props["resource_pool"] = resource_pool
await inventory.upsert_object(
database,
moid=moid,
type_name="VirtualMachine",
name=name,
parent_moid=folder or src.parent_moid or "group-v23",
props=props,
)
task_id = await task_store.create_task(
database,
description=f"Clone {source_vm} -> {moid}",
service="com.vmware.vcenter.vm",
operation="clone",
result={"vm": moid},
)
return moid, task_id
async def customize_vm(database: Database, vm: str, spec: dict[str, Any]) -> str:
obj = await require_vm(database, vm)
props = dict(obj.props)
props["customization"] = spec
if spec.get("hostname") or spec.get("hostName"):
identity = dict(props.get("identity") or {})
identity["name"] = str(spec.get("hostname") or spec.get("hostName"))
props["identity"] = identity
if spec.get("ip") or spec.get("ipAddress"):
props["guest_ip"] = str(spec.get("ip") or spec.get("ipAddress"))
await inventory.update_props(database, vm, props)
return await task_store.create_task(
database,
description=f"Customize {vm}",
service="vim.VirtualMachine",
operation="customize",
result={"vm": vm},
)
async def guest_list_files(database: Database, vm: str, path: str = "/") -> list[dict[str, Any]]:
obj = await require_vm(database, vm)
files = dict(obj.props.get("guest_files") or {})
# Always expose synthetic root filesystem listing.
entries = [
{"path": "/", "type": "DIRECTORY", "size": 0},
{"path": "/tmp", "type": "DIRECTORY", "size": 0},
{"path": "/etc", "type": "DIRECTORY", "size": 0},
]
prefix = path.rstrip("/") or ""
for file_path, meta in files.items():
if prefix and not str(file_path).startswith(prefix) and file_path != path:
continue
entries.append(
{
"path": file_path,
"type": "FILE",
"size": int(
(meta or {}).get("size") or len(str((meta or {}).get("content") or ""))
),
}
)
return entries
async def guest_write_file(
database: Database,
vm: str,
path: str,
content: str,
*,
overwrite: bool = True,
) -> None:
obj = await require_vm(database, vm)
props = dict(obj.props)
files = dict(props.get("guest_files") or {})
if path in files and not overwrite:
raise invalid_argument(f"File exists: {path}")
files[path] = {"content": content, "size": len(content.encode("utf-8"))}
props["guest_files"] = files
await inventory.update_props(database, vm, props)
async def guest_read_file(database: Database, vm: str, path: str) -> str:
obj = await require_vm(database, vm)
files = dict(obj.props.get("guest_files") or {})
if path not in files:
# Seed a lab default for common paths.
if path in {"/etc/hostname", "/etc/hosts"}:
return (
obj.name
if path.endswith("hostname")
else f"127.0.0.1 localhost\n10.0.0.1 {obj.name}\n"
)
raise not_found(f"Guest file not found: {path}")
return str((files[path] or {}).get("content") or "")
async def guest_delete_file(database: Database, vm: str, path: str) -> None:
obj = await require_vm(database, vm)
props = dict(obj.props)
files = dict(props.get("guest_files") or {})
if path not in files:
raise not_found(f"Guest file not found: {path}")
del files[path]
props["guest_files"] = files
await inventory.update_props(database, vm, props)
async def relocate_vm(database: Database, vm: str, host: str | None, datastore: str | None) -> str:
obj = await require_vm(database, vm)
props = dict(obj.props)
if host:
props["host"] = host
if datastore:
props["datastore"] = datastore
await inventory.update_props(database, vm, props)
return await task_store.create_task(
database,
description=f"Relocate {vm}",
service="com.vmware.vcenter.vm",
operation="relocate",
result={"vm": vm, "host": props.get("host"), "datastore": props.get("datastore")},
)
def _pool(database: Database) -> Any:
return database.pool # type: ignore[attr-defined]
async def list_snapshots(database: Database, vm: str) -> list[dict[str, Any]]:
await require_vm(database, vm)
pool = _pool(database)
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, name, description, created_at, props FROM vsphere_snapshots WHERE vm_moid = $1 ORDER BY created_at",
vm,
)
out: list[dict[str, Any]] = []
for row in rows:
props = row["props"]
if isinstance(props, str):
props = json.loads(props)
out.append(
{
"snapshot": row["id"],
"name": row["name"],
"description": row["description"],
"created_at": row["created_at"].isoformat(),
**(props or {}),
}
)
return out
async def create_snapshot(
database: Database,
vm: str,
*,
name: str,
description: str = "",
memory: bool = False,
) -> tuple[str, str]:
await require_vm(database, vm)
snap_id = f"snapshot-{secrets.token_hex(4)}"
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_snapshots (id, vm_moid, name, description, props)
VALUES ($1, $2, $3, $4, $5::jsonb)
""",
snap_id,
vm,
name,
description,
json.dumps({"memory": memory}),
)
task_id = await task_store.create_task(
database,
description=f"Create snapshot {snap_id}",
service="com.vmware.vcenter.vm.snapshots",
operation="create",
result={"snapshot": snap_id},
)
return snap_id, task_id
async def delete_snapshot(database: Database, vm: str, snapshot: str) -> str:
await require_vm(database, vm)
pool = _pool(database)
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM vsphere_snapshots WHERE id = $1 AND vm_moid = $2",
snapshot,
vm,
)
if not result.endswith("1"):
raise not_found(f"Snapshot {snapshot} not found")
return await task_store.create_task(
database,
description=f"Delete snapshot {snapshot}",
service="com.vmware.vcenter.vm.snapshots",
operation="delete",
result={"snapshot": snapshot},
)
async def revert_snapshot(database: Database, vm: str, snapshot: str) -> str:
await require_vm(database, vm)
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id FROM vsphere_snapshots WHERE id = $1 AND vm_moid = $2",
snapshot,
vm,
)
if row is None:
raise not_found(f"Snapshot {snapshot} not found")
return await task_store.create_task(
database,
description=f"Revert snapshot {snapshot}",
service="com.vmware.vcenter.vm.snapshots",
operation="revert",
result={"snapshot": snapshot, "vm": vm},
)
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")
props = dict(obj.props)
props["cpu_count"] = count
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")
props = dict(obj.props)
props["memory_size_mib"] = size_mib
await inventory.update_props(database, vm, props)
async def add_disk(database: Database, vm: str, capacity: int) -> dict[str, Any]:
obj = await require_vm(database, vm)
props = dict(obj.props)
disks = list(props.get("disks") or [])
key = str(2000 + len(disks))
disk = {
"key": key,
"value": {"label": f"Hard disk {len(disks) + 1}", "capacity": capacity, "type": "SCSI"},
}
disks.append(disk)
props["disks"] = disks
await inventory.update_props(database, vm, props)
return disk
async def add_nic(database: Database, vm: str, network: str = "network-41") -> dict[str, Any]:
obj = await require_vm(database, vm)
props = dict(obj.props)
nics = list(props.get("nics") or [])
key = str(4000 + len(nics))
nic = {
"key": key,
"value": {
"label": f"Network adapter {len(nics) + 1}",
"mac": f"00:50:56:01:{len(nics):02x}:{obj.moid[-2:]}",
"state": "CONNECTED",
"backing": {"type": "STANDARD_PORTGROUP", "network": network},
},
}
nics.append(nic)
props["nics"] = nics
networks = list(props.get("networks") or [])
if network not in networks:
networks.append(network)
props["networks"] = networks
await inventory.update_props(database, vm, props)
return nic
async def set_template(database: Database, vm: str, *, template: bool) -> str:
obj = await require_vm(database, vm)
if template and obj.props.get("power_state") == "POWERED_ON":
raise invalid_argument("Power off VM before converting to template")
props = dict(obj.props)
props["template"] = template
await inventory.update_props(database, vm, props)
return await task_store.create_task(
database,
description=f"{'MarkAsTemplate' if template else 'MarkAsVirtualMachine'} {vm}",
service="com.vmware.vcenter.vm",
operation="mark_as_template" if template else "mark_as_vm",
result={"vm": vm, "template": template},
)
async def unregister_vm(database: Database, vm: str) -> str:
await require_vm(database, vm)
await inventory.delete_object(database, vm)
return await task_store.create_task(
database,
description=f"Unregister {vm}",
service="com.vmware.vcenter.vm",
operation="unregister",
result={"vm": vm},
)
async def guest_networking(database: Database, vm: str) -> dict[str, Any]:
obj = await require_vm(database, vm)
networking = obj.props.get("guest_networking")
return networking if isinstance(networking, dict) else {}
async def console_ticket(database: Database, vm: str) -> dict[str, Any]:
obj = await require_vm(database, vm)
import json
import secrets
ticket = secrets.token_urlsafe(24)
payload = {
"ticket": ticket,
"cfg_file": f"[{obj.props.get('datastore')}] {obj.name}/{obj.name}.vmx",
"host": obj.props.get("host"),
"port": 443,
"ssl_thumbprint": obj.props.get("ssl_thumbprint") or "",
"vm": vm,
}
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_console_tickets (ticket, vm_moid, payload)
VALUES ($1, $2, $3::jsonb)
""",
ticket,
vm,
json.dumps(payload),
)
return payload
+107
View File
@@ -0,0 +1,107 @@
"""vSphere Automation API error shapes."""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
class VsphereError(HTTPException):
"""HTTPException with a vSphere Automation-style JSON body."""
def __init__(
self,
status_code: int,
*,
error_type: str,
messages: list[dict[str, Any]] | None = None,
data: dict[str, Any] | None = None,
) -> None:
detail: dict[str, Any] = {
"error_type": error_type,
"messages": messages or [{"default_message": error_type, "id": error_type, "args": []}],
}
if data is not None:
detail["data"] = data
super().__init__(status_code=status_code, detail=detail)
def unauthenticated(message: str = "Authentication required") -> VsphereError:
return VsphereError(
401,
error_type="unauthenticated",
messages=[
{
"default_message": message,
"id": "com.vmware.vapi.endpoint.unauthenticated",
"args": [],
}
],
)
def not_found(message: str = "Not found") -> VsphereError:
return VsphereError(
404,
error_type="not_found",
messages=[
{"default_message": message, "id": "com.vmware.vapi.std.errors.not_found", "args": []}
],
)
def already_exists(message: str = "Already exists") -> VsphereError:
return VsphereError(
400,
error_type="already_exists",
messages=[
{
"default_message": message,
"id": "com.vmware.vapi.std.errors.already_exists",
"args": [],
}
],
)
def invalid_argument(message: str = "Invalid argument") -> VsphereError:
return VsphereError(
400,
error_type="invalid_argument",
messages=[
{
"default_message": message,
"id": "com.vmware.vapi.std.errors.invalid_argument",
"args": [],
}
],
)
def unauthorized(message: str = "Unauthorized") -> VsphereError:
return VsphereError(
403,
error_type="unauthorized",
messages=[
{
"default_message": message,
"id": "com.vmware.vapi.std.errors.unauthorized",
"args": [],
}
],
)
def not_implemented(message: str = "Not implemented for active contract version") -> VsphereError:
return VsphereError(
501,
error_type="error",
messages=[
{
"default_message": message,
"id": "com.vmware.vapi.std.errors.error",
"args": [],
}
],
)
+190
View File
@@ -0,0 +1,190 @@
"""Persistent vSphere managed-object inventory."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from app.db.pool import Database
@dataclass(frozen=True, slots=True)
class ManagedObject:
moid: str
type: str
name: str
parent_moid: str | None
props: dict[str, Any]
def _pool(database: Database) -> Any:
return database.pool # type: ignore[attr-defined]
async def count_objects(database: Database) -> int:
pool = _pool(database)
async with pool.acquire() as conn:
return int(await conn.fetchval("SELECT COUNT(*) FROM vsphere_objects") or 0)
async def list_objects(
database: Database,
*,
type_name: str | None = None,
) -> list[ManagedObject]:
pool = _pool(database)
async with pool.acquire() as conn:
if type_name is None:
rows = await conn.fetch(
"SELECT moid, type, name, parent_moid, props FROM vsphere_objects ORDER BY type, name"
)
else:
rows = await conn.fetch(
"""
SELECT moid, type, name, parent_moid, props FROM vsphere_objects
WHERE type = $1 ORDER BY name
""",
type_name,
)
return [_row(row) for row in rows]
async def get_object(database: Database, moid: str) -> ManagedObject | None:
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT moid, type, name, parent_moid, props FROM vsphere_objects WHERE moid = $1",
moid,
)
return None if row is None else _row(row)
async def upsert_object(
database: Database,
*,
moid: str,
type_name: str,
name: str,
parent_moid: str | None,
props: dict[str, Any],
) -> None:
await upsert_objects_batch(
database,
[
{
"moid": moid,
"type": type_name,
"name": name,
"parent_moid": parent_moid,
"props": props,
}
],
)
async def upsert_objects_batch(database: Database, rows: list[dict[str, Any]]) -> None:
if not rows:
return
pool = _pool(database)
payload = [
(
str(row["moid"]),
str(row["type"]),
str(row["name"]),
None if row.get("parent_moid") is None else str(row["parent_moid"]),
json.dumps(row.get("props") or {}),
)
for row in rows
]
async with pool.acquire() as conn:
await conn.executemany(
"""
INSERT INTO vsphere_objects (moid, type, name, parent_moid, props)
VALUES ($1, $2, $3, $4, $5::jsonb)
ON CONFLICT (moid) DO UPDATE SET
type = EXCLUDED.type,
name = EXCLUDED.name,
parent_moid = EXCLUDED.parent_moid,
props = EXCLUDED.props,
updated_at = now()
""",
payload,
)
async def count_by_type(database: Database) -> dict[str, int]:
pool = _pool(database)
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT type, COUNT(*)::int AS count FROM vsphere_objects GROUP BY type ORDER BY type"
)
return {str(row["type"]): int(row["count"]) for row in rows}
async def update_props(database: Database, moid: str, props: dict[str, Any]) -> ManagedObject:
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE vsphere_objects
SET props = $2::jsonb, updated_at = now()
WHERE moid = $1
RETURNING moid, type, name, parent_moid, props
""",
moid,
json.dumps(props),
)
if row is None:
raise KeyError(moid)
return _row(row)
async def delete_object(database: Database, moid: str) -> bool:
pool = _pool(database)
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM vsphere_objects WHERE moid = $1", moid)
return result.endswith("1")
async def next_moid(database: Database, prefix: str) -> str:
"""Allocate the next MoID under an advisory lock (safe under concurrent create).
Inserts a reservation row before releasing the lock so two creators cannot
compute the same next id between allocate and upsert.
"""
pool = _pool(database)
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("SELECT pg_advisory_xact_lock(hashtext($1))", f"moid:{prefix}")
rows = await conn.fetch(
"SELECT moid FROM vsphere_objects WHERE moid LIKE $1",
f"{prefix}-%",
)
numbers: list[int] = []
for row in rows:
suffix = str(row["moid"]).removeprefix(f"{prefix}-")
if suffix.isdigit():
numbers.append(int(suffix))
moid = f"{prefix}-{max(numbers, default=100) + 1}"
await conn.execute(
"""
INSERT INTO vsphere_objects (moid, type, name, parent_moid, props)
VALUES ($1, 'MoIdReservation', $1, NULL, '{}'::jsonb)
""",
moid,
)
return moid
def _row(row: Any) -> ManagedObject:
props = row["props"]
if isinstance(props, str):
props = json.loads(props)
return ManagedObject(
moid=str(row["moid"]),
type=str(row["type"]),
name=str(row["name"]),
parent_moid=None if row["parent_moid"] is None else str(row["parent_moid"]),
props=dict(props or {}),
)
+468
View File
@@ -0,0 +1,468 @@
"""Declarative vSphere inventory profiles (small lab / large cluster)."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class ObjectSpec:
moid: str
type: str
name: str
parent_moid: str | None
props: dict[str, Any]
@dataclass(frozen=True, slots=True)
class CredentialSpec:
username: str
password: str
roles: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class PermissionSpec:
principal: str
role: str
entity_moid: str | None
propagate: bool = True
@dataclass(frozen=True, slots=True)
class VsphereSeedProfile:
name: str
objects: tuple[ObjectSpec, ...]
credentials: tuple[CredentialSpec, ...]
permissions: tuple[PermissionSpec, ...]
host_count: int
vm_count: int
POWER_CYCLE = ("POWERED_ON", "POWERED_ON", "POWERED_ON", "POWERED_OFF", "SUSPENDED")
GUEST_OS = ("UBUNTU_64", "CENTOS_7_64", "WINDOWS_9_64", "RHEL_8_64", "OTHER_GUEST_64")
ROLE_PREFIX = (
"web",
"app",
"db",
"cache",
"batch",
"jump",
"ci",
"mon",
"log",
"ml",
)
def _topology(
*,
host_count: int,
datastore_count: int = 4,
network_count: int = 3,
) -> list[ObjectSpec]:
specs: list[ObjectSpec] = [
ObjectSpec("group-d1", "Folder", "Datacenters", None, {"folder_type": "DATACENTER"}),
ObjectSpec(
"datacenter-21",
"Datacenter",
"Datacenter",
"group-d1",
{
"datastore_folder": "group-s23",
"host_folder": "group-h23",
"vm_folder": "group-v23",
"network_folder": "group-n23",
},
),
ObjectSpec("group-h23", "Folder", "host", "datacenter-21", {"folder_type": "HOST"}),
ObjectSpec(
"group-v23", "Folder", "vm", "datacenter-21", {"folder_type": "VIRTUAL_MACHINE"}
),
ObjectSpec(
"group-s23", "Folder", "datastore", "datacenter-21", {"folder_type": "DATASTORE"}
),
ObjectSpec("group-n23", "Folder", "network", "datacenter-21", {"folder_type": "NETWORK"}),
ObjectSpec(
"domain-c21",
"ClusterComputeResource",
"Cluster",
"group-h23",
{"drs_enabled": True, "ha_enabled": True, "resource_pool": "resgroup-22"},
),
ObjectSpec(
"resgroup-22",
"ResourcePool",
"Resources",
"domain-c21",
{"cpu_limit_mhz": -1, "memory_limit_mib": -1},
),
# Workload folders for realism
ObjectSpec(
"group-v100", "Folder", "production", "group-v23", {"folder_type": "VIRTUAL_MACHINE"}
),
ObjectSpec(
"group-v101", "Folder", "staging", "group-v23", {"folder_type": "VIRTUAL_MACHINE"}
),
ObjectSpec(
"group-v102", "Folder", "templates", "group-v23", {"folder_type": "VIRTUAL_MACHINE"}
),
]
for index in range(1, host_count + 1):
moid = f"host-{10 + index}"
specs.append(
ObjectSpec(
moid,
"HostSystem",
f"esxi{index:02d}.lab.local",
"domain-c21",
{
"connection_state": "CONNECTED",
"power_state": "POWERED_ON",
"cpu_cores": 32 if index % 3 else 64,
"cpu_mhz": 2500,
"memory_size_mib": 262144 if index % 2 else 524288,
"ip_address": f"192.168.1.{10 + index}",
"version": "8.0.2",
"cluster": "domain-c21",
"maintenance_mode": False,
"networking": {
"dns": {"servers": ["8.8.8.8", "1.1.1.1"], "domains": ["lab.local"]},
"routing": {"default_gateway": "192.168.1.1"},
"interfaces": [
{
"name": "vmk0",
"mac": f"00:50:56:00:{index:02x}:01",
"ipv4": {"address": f"192.168.1.{10 + index}", "prefix": 24},
}
],
},
"storage_devices": [
{
"device": f"naa.lab{index:04d}",
"display_name": f"Local Disk {index}",
"capacity": 1099511627776 * (1 + index % 3),
"ssd": index % 2 == 0,
}
],
},
)
)
for index in range(1, datastore_count + 1):
moid = f"datastore-{30 + index}"
capacity = 1099511627776 * (1 + (index % 3))
specs.append(
ObjectSpec(
moid,
"Datastore",
f"ds-{index:02d}" if index > 1 else "datastore1",
"group-s23",
{
"type": "VMFS" if index % 2 else "NFS",
"capacity": capacity,
"free_space": capacity // 2,
"accessible": True,
"multiple_host_access": True,
},
)
)
specs.append(
ObjectSpec(
"network-41",
"Network",
"VM Network",
"group-n23",
{"type": "STANDARD_PORTGROUP"},
)
)
for index in range(2, network_count + 1):
specs.append(
ObjectSpec(
f"dvportgroup-{40 + index}",
"DistributedVirtualPortgroup",
f"dvpg-vlan{100 + index}",
"group-n23",
{"type": "DISTRIBUTED_PORTGROUP", "vlan_id": 100 + index},
)
)
specs.append(
ObjectSpec(
"dvs-51",
"VmwareDistributedVirtualSwitch",
"DSwitch",
"group-n23",
{"version": "8.0.0", "mtu": 9000},
)
)
return specs
def _vm_device_props(*, name: str, power: str, index: int, nic_mac: str) -> dict[str, Any]:
"""Shared hardware / guest fields for lab VMs (used by API surface + SOAP)."""
guest_ip = f"10.0.{(index // 250) % 250}.{(index % 250) or 1}"
return {
"nics": [
{
"key": "4000",
"value": {
"label": "Network adapter 1",
"mac": nic_mac,
"state": "CONNECTED" if power == "POWERED_ON" else "NOT_CONNECTED",
"type": "VMXNET3",
"backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"},
},
}
],
"disks": [
{
"key": "2000",
"value": {
"label": "Hard disk 1",
"capacity": 42949672960 + (index % 5) * 10737418240,
"type": "SCSI",
},
}
],
"cdroms": [
{
"cdrom": "3000",
"label": "CD/DVD drive 1",
"state": "CONNECTED",
"backing": {"type": "ISO_FILE", "iso_file": "[datastore1] ISO/ubuntu.iso"},
}
],
"floppies": [{"floppy": "8000", "state": "NOT_CONNECTED"}],
"serials": [{"port": "9000", "yield_on_poll": True}],
"parallels": [{"port": "10000", "yield_on_poll": True}],
"scsi_adapters": [
{"adapter": "1000", "type": "LSILOGIC", "sharing": "NONE", "pci_slot_number": 16}
],
"sata_adapters": [{"adapter": "15000", "bus": 0, "pci_slot_number": 33}],
"nvme_adapters": [{"adapter": "19000", "bus": 0, "pci_slot_number": 160}],
"boot": {
"type": "BIOS",
"delay": 0,
"retry": False,
"retry_delay": 10000,
"enter_setup_mode": False,
},
"boot_devices": [{"type": "CDROM"}, {"type": "DISK"}, {"type": "ETHERNET"}],
"guest_ip": guest_ip,
"guest_filesystems": {
"filesystems": {
"/": {"capacity": 42949672960, "free_space": 21474836480},
}
},
"guest_networking": {
"dns": {"ip_addresses": ["8.8.8.8"], "host_name": name, "domain_name": "lab.local"},
"ip": {
"ip_addresses": [
{
"ip_address": guest_ip,
"prefix_length": 24,
"state": "PREFERRED",
}
]
},
},
"customization": {
"name": name,
"status": "PENDING",
"spec": {"hostname": name, "domain": "lab.local"},
},
"tools": {
"auto_update_supported": True,
"install_attempted": True,
"run_state": "RUNNING" if power == "POWERED_ON" else "NOT_RUNNING",
"upgrade_policy": "MANUAL",
"version_number": 12320,
"version_status": "CURRENT",
},
"cpu": {"cores_per_socket": 1, "hot_add_enabled": False},
"memory": {"hot_add_enabled": False},
"identity": {
"name": name,
"instance_uuid": f"5029{index:04d}-0000-0000-0000-{index:012d}",
},
}
def _vm_spec(index: int, *, host_count: int) -> ObjectSpec:
moid = f"vm-{100 + index}"
role = ROLE_PREFIX[index % len(ROLE_PREFIX)]
name = f"{role}-{index:04d}"
power = POWER_CYCLE[index % len(POWER_CYCLE)]
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]
guest = GUEST_OS[index % len(GUEST_OS)]
ds_index = 1 + (index % 4)
nic_tail = f"{(index % 250):02x}"
devices = _vm_device_props(
name=name,
power=power,
index=index,
nic_mac=f"00:50:56:01:{(index // 256) % 256:02x}:{nic_tail}",
)
return ObjectSpec(
moid,
"VirtualMachine",
name,
folder,
{
"power_state": power,
"cpu_count": cpus,
"memory_size_mib": memory,
"guest_OS": guest,
"hardware_version": "VMX_19",
"host": host,
"datastore": f"datastore-{30 + ds_index}",
"resource_pool": "resgroup-22",
"networks": ["network-41"],
"template": False,
"tools_status": "GUEST_TOOLS_RUNNING"
if power == "POWERED_ON"
else "GUEST_TOOLS_NOT_RUNNING",
**devices,
},
)
def lab_credentials() -> tuple[CredentialSpec, ...]:
return (
CredentialSpec("administrator@vsphere.local", "VMware1!", ("Administrator",)),
CredentialSpec("readonly@vsphere.local", "VMware1!", ("ReadOnly",)),
CredentialSpec("operator@vsphere.local", "VMware1!", ("VirtualMachinePowerUser",)),
CredentialSpec("vmadmin@vsphere.local", "VMware1!", ("VirtualMachineAdministrator",)),
)
def lab_permissions() -> tuple[PermissionSpec, ...]:
return (
PermissionSpec("administrator@vsphere.local", "Administrator", None, True),
PermissionSpec("readonly@vsphere.local", "ReadOnly", "datacenter-21", True),
PermissionSpec("operator@vsphere.local", "VirtualMachinePowerUser", "group-v23", True),
PermissionSpec("vmadmin@vsphere.local", "VirtualMachineAdministrator", "group-v23", True),
)
def small_vsphere_profile() -> VsphereSeedProfile:
"""Compact seed used by unit/integration tests (named VMs)."""
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),
("db-01", "POWERED_ON", "host-13", 4, 8192),
("app-01", "POWERED_OFF", "host-11", 2, 2048),
("jumpbox", "SUSPENDED", "host-12", 1, 1024),
)
vms: list[ObjectSpec] = []
for index, (name, power, host, cpus, memory) in enumerate(named, start=1):
moid = f"vm-{100 + index}"
devices = _vm_device_props(
name=name,
power=power,
index=index,
nic_mac=f"00:50:56:01:00:{moid[-2:]}",
)
devices["identity"]["instance_uuid"] = f"5029{moid[-3:]}-0000-0000-0000-000000000000"
vms.append(
ObjectSpec(
moid,
"VirtualMachine",
name,
"group-v23",
{
"power_state": power,
"cpu_count": cpus,
"memory_size_mib": memory,
"guest_OS": "UBUNTU_64",
"hardware_version": "VMX_19",
"host": host,
"datastore": "datastore-31",
"resource_pool": "resgroup-22",
"networks": ["network-41"],
"template": False,
"tools_status": "GUEST_TOOLS_RUNNING"
if power == "POWERED_ON"
else "GUEST_TOOLS_NOT_RUNNING",
**devices,
},
)
)
return VsphereSeedProfile(
name="small",
objects=tuple(objects + vms),
credentials=lab_credentials(),
permissions=lab_permissions(),
host_count=3,
vm_count=5,
)
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),
)
def demo_cluster_vsphere_profile() -> VsphereSeedProfile:
"""Enterprise-shaped cluster: 20 hosts, 1000 VMs (aligned with Proxmox 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,
)
def build_vsphere_profile(
name: str | None = None,
*,
large_hosts: int | None = None,
large_vms: int | None = None,
) -> VsphereSeedProfile:
profile_name = (name or os.getenv("SEED_VSPHERE_PROFILE") or "large").strip().lower()
hosts = (
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"}:
return small_vsphere_profile()
if profile_name in {"demo-cluster", "demo", "enterprise"}:
return demo_cluster_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 props_json(props: dict[str, Any]) -> str:
return json.dumps(props)
+46
View File
@@ -0,0 +1,46 @@
"""vSphere Automation REST routers.
Package init stays free of FastAPI so probes (e.g. pulumi-tests) can import
``app.vsphere.rest.coverage`` / matrix helpers without the full app stack.
``vsphere_rest_router`` is assembled lazily on first access.
"""
from __future__ import annotations
from typing import Any
__all__ = ["vsphere_rest_router"]
def __getattr__(name: str) -> Any:
if name == "vsphere_rest_router":
from fastapi import APIRouter
from app.vsphere.rest.appliance_ext import router as appliance_ext_router
from app.vsphere.rest.content_rest import router as content_router
from app.vsphere.rest.inventory_ext import router as inventory_ext_router
from app.vsphere.rest.legacy import router as legacy_router
from app.vsphere.rest.nfc_rest import router as nfc_router
from app.vsphere.rest.platform_rest import router as platform_router
from app.vsphere.rest.router import router as core_router
from app.vsphere.rest.stub_surface import router as stub_surface_router
from app.vsphere.rest.tagging_rest import router as tagging_router
from app.vsphere.rest.tasks import router as tasks_router
from app.vsphere.rest.vm_ext import router as vm_ext_router
vsphere_rest_router = APIRouter()
vsphere_rest_router.include_router(core_router)
vsphere_rest_router.include_router(tasks_router)
vsphere_rest_router.include_router(vm_ext_router)
vsphere_rest_router.include_router(inventory_ext_router)
vsphere_rest_router.include_router(tagging_router)
vsphere_rest_router.include_router(content_router)
vsphere_rest_router.include_router(appliance_ext_router)
vsphere_rest_router.include_router(platform_router)
vsphere_rest_router.include_router(nfc_router)
vsphere_rest_router.include_router(legacy_router)
# Broadcom universe per-path stubs — must stay last so deep handlers win.
vsphere_rest_router.include_router(stub_surface_router)
globals()["vsphere_rest_router"] = vsphere_rest_router
return vsphere_rest_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+123
View File
@@ -0,0 +1,123 @@
"""Appliance health / networking / timesync — mutable lab state."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import appliance
from app.vsphere.errors import invalid_argument
from app.vsphere.security.authz import require_admin, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Appliance"])
@router.get("/api/appliance/health/system")
async def health_system(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/appliance/health/system")
return payload if isinstance(payload, dict) else {"status": "unknown", "messages": []}
@router.get("/api/appliance/networking")
async def networking(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await appliance.get_networking(database)
@router.get("/api/appliance/networking/dns/hostname")
async def get_dns_hostname(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, str]:
return {"name": await appliance.get_hostname(database)}
@router.put("/api/appliance/networking/dns/hostname")
@router.post("/api/appliance/networking/dns/hostname")
async def set_dns_hostname(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, str]:
name = str(body.get("name") or body.get("hostname") or "").strip()
if not name:
raise invalid_argument("name is required")
hostname = await appliance.set_hostname(database, name)
return {"name": hostname}
@router.get("/api/appliance/networking/dns/servers")
async def get_dns_servers(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
networking_state = await appliance.get_networking(database)
dns = networking_state.get("dns") or {}
return {"mode": dns.get("mode"), "servers": list(dns.get("servers") or [])}
@router.put("/api/appliance/networking/dns/servers")
@router.post("/api/appliance/networking/dns/servers")
async def set_dns_servers(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
mode = body.get("mode")
servers = body.get("servers")
if servers is None and body.get("server"):
servers = [body["server"]]
return await appliance.set_dns_servers(
database,
mode=str(mode) if mode is not None else None,
servers=[str(s) for s in servers] if isinstance(servers, list) else None,
)
@router.get("/api/appliance/networking/dns/domains")
async def get_dns_domains(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
networking_state = await appliance.get_networking(database)
dns = networking_state.get("dns") or {}
return list(dns.get("domains") or [])
@router.put("/api/appliance/networking/dns/domains")
@router.post("/api/appliance/networking/dns/domains")
async def set_dns_domains(
body: dict[str, Any] | list[Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> list[str]:
if isinstance(body, list):
domains = [str(d) for d in body]
else:
domains = [str(d) for d in (body.get("domains") or body.get("domain") or [])]
if isinstance(body.get("domain"), str):
domains = [str(body["domain"])]
if not domains:
from app.vsphere.errors import invalid_argument
raise invalid_argument("domains is required")
return await appliance.set_dns_domains(database, domains)
@router.get("/api/appliance/timesync")
async def timesync(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await appliance.get_timesync(database)
+191
View File
@@ -0,0 +1,191 @@
"""Content library, OVF deploy, storage policies, privileges."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import api_state, content
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Content"])
@router.get("/api/content/library")
async def list_libraries(
database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[str]:
return [item["id"] for item in await content.list_libraries(database)]
@router.post("/api/content/local-library")
async def create_library(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.CreateLocalLibrary")),
) -> str:
from app.vsphere.errors import invalid_argument
spec = body.get("create_spec") or body
name = spec.get("name") if isinstance(spec, dict) else None
if not name:
raise invalid_argument("create_spec.name is required")
return await content.create_library(
database, name=str(name), description=str(spec.get("description") or "")
)
@router.get("/api/content/library/item")
async def list_items(
library_id: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
return [item["id"] for item in await content.list_library_items(database, library_id)]
@router.post("/api/content/library/item")
async def create_item(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> str:
from app.vsphere.errors import invalid_argument
spec = body.get("create_spec") or body
if not isinstance(spec, dict):
raise invalid_argument("create_spec is required")
library_id = spec.get("library_id")
name = spec.get("name")
if not library_id:
raise invalid_argument("create_spec.library_id is required")
if not name:
raise invalid_argument("create_spec.name is required")
return await content.create_library_item(
database,
library_id=str(library_id),
name=str(name),
item_type=str(spec.get("type") or "ovf"),
description=str(spec.get("description") or ""),
)
@router.post("/api/vcenter/ovf/library-item/{item_id}")
async def deploy_ovf(
item_id: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.DeployTemplate")),
) -> dict[str, Any]:
target = body.get("target") or {}
deployment = body.get("deployment_spec") or body
moid, task_id = await content.deploy_ovf_from_library(
database,
item_id=item_id,
name=str(deployment.get("name") or f"ovf-{item_id[-6:]}"),
folder=str(target.get("folder") or "group-v23"),
host=str(target.get("host") or "host-11"),
datastore=str(target.get("datastore") or "datastore-31"),
)
return {"resource_id": {"id": moid, "type": "VirtualMachine"}, "task": task_id}
@router.post("/api/content/library/item/update-session")
async def create_update_session(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> str:
create_spec = body.get("create_spec") or body
library_item_id = str(create_spec.get("library_item_id") or create_spec.get("item_id") or "")
if not library_item_id:
from app.vsphere.errors import invalid_argument
raise invalid_argument("create_spec.library_item_id is required")
return await content.create_update_session(database, library_item_id=library_item_id)
@router.get("/api/content/library/item/update-session/{session_id}")
async def get_update_session(
session_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await content.get_update_session(database, session_id)
@router.post("/api/content/library/item/update-session/{session_id}/file")
async def add_update_session_file(
session_id: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> dict[str, Any]:
file_spec = body.get("file_spec") or body
return await content.add_update_session_file(
database,
session_id,
name=str(file_spec.get("name") or "upload.bin"),
source_type=str(file_spec.get("source_type") or "PUSH"),
size=int(file_spec.get("size") or 0),
content=str(file_spec.get("content") or ""),
)
@router.post("/api/content/library/item/update-session/{session_id}")
async def complete_update_session(
session_id: str,
action: str = Query("complete"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("ContentLibrary.AddLibraryItem")),
) -> None:
del action
await content.complete_update_session(database, session_id)
@router.post("/api/content/library/item/download-session")
async def create_download_session(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> str:
create_spec = body.get("create_spec") or body
library_item_id = str(create_spec.get("library_item_id") or create_spec.get("item_id") or "")
if not library_item_id:
from app.vsphere.errors import invalid_argument
raise invalid_argument("create_spec.library_item_id is required")
return await content.create_download_session(database, library_item_id=library_item_id)
@router.get("/api/content/library/item/download-session/{session_id}")
async def get_download_session(
session_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await content.get_download_session(database, session_id)
@router.get("/api/content/library/item/download-session/{session_id}/file")
async def list_download_session_files(
session_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
return await content.list_download_session_files(database, session_id)
@router.get("/api/vcenter/storage/policies")
async def storage_policies(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
payload = await api_state.get_payload(database, "GET", "/api/vcenter/storage/policies")
if isinstance(payload, list):
return payload
return []
+222
View File
@@ -0,0 +1,222 @@
"""Implemented REST path registry for UI catalog and surface probes.
CORE_* entries are deep lab handlers. Broadcom Automation API routes from
``universe.json`` (generated from the public operations index) fill the rest as
thin stubs — each ``(verb, path)`` is registered separately in ``stub_surface``.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
ACTIVE_STATUSES = frozenset({"implemented", "stub"})
# verb + path template → status (deep handlers)
CORE_IMPLEMENTED: dict[tuple[str, str], str] = {
("POST", "/api/session"): "implemented",
("DELETE", "/api/session"): "implemented",
("GET", "/api/session"): "implemented",
("POST", "/rest/com/vmware/cis/session"): "implemented",
("GET", "/rest/com/vmware/cis/session"): "implemented",
("DELETE", "/rest/com/vmware/cis/session"): "implemented",
("GET", "/api/cis/tasks"): "implemented",
("GET", "/api/cis/tasks/{task}"): "implemented",
("GET", "/api/appliance/system/version"): "implemented",
("GET", "/api/appliance/health/system"): "implemented",
("GET", "/api/appliance/networking"): "implemented",
("GET", "/api/appliance/networking/dns/hostname"): "implemented",
("PUT", "/api/appliance/networking/dns/hostname"): "implemented",
("POST", "/api/appliance/networking/dns/hostname"): "implemented",
("GET", "/api/appliance/networking/dns/servers"): "implemented",
("PUT", "/api/appliance/networking/dns/servers"): "implemented",
("POST", "/api/appliance/networking/dns/servers"): "implemented",
("GET", "/api/appliance/networking/dns/domains"): "implemented",
("PUT", "/api/appliance/networking/dns/domains"): "implemented",
("POST", "/api/appliance/networking/dns/domains"): "implemented",
("GET", "/api/appliance/timesync"): "implemented",
("GET", "/api/vcenter/vm"): "implemented",
("POST", "/api/vcenter/vm"): "implemented",
("GET", "/api/vcenter/vm/{vm}"): "implemented",
("DELETE", "/api/vcenter/vm/{vm}"): "implemented",
("GET", "/api/vcenter/vm/{vm}/power"): "implemented",
("POST", "/api/vcenter/vm/{vm}/power"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/identity"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/local-filesystem"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/filesystem"): "implemented",
("PUT", "/api/vcenter/vm/{vm}/guest/filesystem"): "implemented",
("DELETE", "/api/vcenter/vm/{vm}/guest/filesystem"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/filesystem/files"): "implemented",
("GET", "/api/vcenter/vm/{vm}/tools"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/cpu"): "implemented",
("PATCH", "/api/vcenter/vm/{vm}/hardware/cpu"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/memory"): "implemented",
("PATCH", "/api/vcenter/vm/{vm}/hardware/memory"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/disk"): "implemented",
("POST", "/api/vcenter/vm/{vm}/hardware/disk"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/ethernet"): "implemented",
("POST", "/api/vcenter/vm/{vm}/hardware/ethernet"): "implemented",
("GET", "/api/vcenter/vm/{vm}/hardware/boot"): "implemented",
("GET", "/api/vcenter/vm/{vm}/snapshots"): "implemented",
("POST", "/api/vcenter/vm/{vm}/snapshots"): "implemented",
("DELETE", "/api/vcenter/vm/{vm}/snapshots/{snapshot}"): "implemented",
("POST", "/api/vcenter/vm/{vm}/snapshots/{snapshot}"): "implemented",
("POST", "/api/vcenter/vm/{vm}/clone"): "implemented",
("POST", "/api/vcenter/vm/{vm}/relocate"): "implemented",
("GET", "/api/vcenter/host"): "implemented",
("GET", "/api/vcenter/host/{host}"): "implemented",
("POST", "/api/vcenter/host/{host}/maintenance"): "implemented",
("GET", "/api/vcenter/datastore"): "implemented",
("GET", "/api/vcenter/datastore/{datastore}"): "implemented",
("GET", "/api/vcenter/datastore/{datastore}/files"): "implemented",
("POST", "/api/vcenter/datastore/{datastore}/files"): "implemented",
("GET", "/api/vcenter/network"): "implemented",
("GET", "/api/vcenter/datacenter"): "implemented",
("POST", "/api/vcenter/datacenter"): "implemented",
("DELETE", "/api/vcenter/datacenter/{datacenter}"): "implemented",
("GET", "/api/vcenter/cluster"): "implemented",
("POST", "/api/vcenter/cluster"): "implemented",
("DELETE", "/api/vcenter/cluster/{cluster}"): "implemented",
("GET", "/api/vcenter/folder"): "implemented",
("POST", "/api/vcenter/folder"): "implemented",
("POST", "/api/vcenter/folder/{folder}"): "implemented",
("DELETE", "/api/vcenter/folder/{folder}"): "implemented",
("GET", "/api/vcenter/resource-pool"): "implemented",
("POST", "/api/vcenter/resource-pool"): "implemented",
("DELETE", "/api/vcenter/resource-pool/{resource_pool}"): "implemented",
("GET", "/api/vcenter/network/dvs"): "implemented",
("POST", "/api/vcenter/network/dvs"): "implemented",
("POST", "/api/vcenter/network/dvpg"): "implemented",
("GET", "/api/cis/tagging/category"): "implemented",
("POST", "/api/cis/tagging/category"): "implemented",
("GET", "/api/cis/tagging/category/{category_id}"): "implemented",
("DELETE", "/api/cis/tagging/category/{category_id}"): "implemented",
("GET", "/api/cis/tagging/tag"): "implemented",
("POST", "/api/cis/tagging/tag"): "implemented",
("GET", "/api/cis/tagging/tag/{tag_id}"): "implemented",
("DELETE", "/api/cis/tagging/tag/{tag_id}"): "implemented",
("POST", "/api/cis/tagging/tag-association"): "implemented",
("GET", "/api/content/library"): "implemented",
("POST", "/api/content/local-library"): "implemented",
("GET", "/api/content/library/item"): "implemented",
("POST", "/api/content/library/item"): "implemented",
("POST", "/api/content/library/item/update-session"): "implemented",
("GET", "/api/content/library/item/update-session/{session_id}"): "implemented",
("POST", "/api/content/library/item/update-session/{session_id}"): "implemented",
("POST", "/api/content/library/item/update-session/{session_id}/file"): "implemented",
("POST", "/api/content/library/item/download-session"): "implemented",
("GET", "/api/content/library/item/download-session/{session_id}"): "implemented",
("GET", "/api/content/library/item/download-session/{session_id}/file"): "implemented",
("POST", "/api/vcenter/ovf/library-item/{item_id}"): "implemented",
("GET", "/api/vcenter/storage/policies"): "implemented",
("GET", "/api/vcenter/storage/policies/{policy}/vm"): "implemented",
("GET", "/api/vcenter/privilege"): "implemented",
("GET", "/api/vcenter/authorization/roles"): "implemented",
("GET", "/api/vcenter/authorization/permissions"): "implemented",
("POST", "/api/vcenter/authorization/permissions"): "implemented",
("DELETE", "/api/vcenter/authorization/permissions/{permission_id}"): "implemented",
("GET", "/api/vcenter/namespaces"): "implemented",
("GET", "/api/vcenter/namespace-management/nsx-tier0-gateway"): "implemented",
("GET", "/api/vcenter/namespace-management/networks"): "implemented",
("GET", "/api/vcenter/namespace-management/virtual-machine-classes"): "implemented",
("GET", "/api/vcenter/identity/providers"): "implemented",
("POST", "/api/vcenter/identity/providers"): "implemented",
("GET", "/api/vcenter/identity/providers/{provider}"): "implemented",
("PUT", "/api/vcenter/identity/providers/{provider}"): "implemented",
("PATCH", "/api/vcenter/identity/providers/{provider}"): "implemented",
("DELETE", "/api/vcenter/identity/providers/{provider}"): "implemented",
("GET", "/api/vcenter/certificate-management/vcenter/tls-csr"): "implemented",
("POST", "/api/vcenter/certificate-management/vcenter/tls-csr"): "implemented",
("GET", "/api/vcenter/certificate-management/vcenter/trusted-root-chains"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/customization"): "implemented",
("GET", "/api/vcenter/certificate-management/vcenter/tls"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/networking"): "implemented",
("GET", "/api/vcenter/vm/{vm}/guest/power"): "implemented",
("POST", "/api/vcenter/vm/{vm}/guest/power"): "implemented",
("POST", "/api/vcenter/vm/{vm}/tools"): "implemented",
("POST", "/api/vcenter/vm/{vm}/console/tickets"): "implemented",
("POST", "/api/vcenter/vm/{vm}/guest/customization"): "implemented",
("POST", "/api/vcenter/vm/{vm}"): "implemented",
("GET", "/api/vcenter/host/{host}/storage/storage-device"): "implemented",
("GET", "/api/vcenter/host/{host}/networking"): "implemented",
("GET", "/api/vcenter/folder/{folder}/children"): "implemented",
("GET", "/api/vapi/metadata/metamodel/service"): "implemented",
("GET", "/api/vapi/metadata/authentication/component"): "implemented",
("GET", "/api/vcenter/activity-history"): "implemented",
("GET", "/rest/vcenter/vm"): "implemented",
("GET", "/rest/vcenter/vm/{vm}"): "implemented",
("POST", "/rest/vcenter/vm/{vm}/power"): "implemented",
("GET", "/rest/vcenter/host"): "implemented",
("GET", "/rest/vcenter/datastore"): "implemented",
("GET", "/rest/vcenter/network"): "implemented",
("GET", "/rest/vcenter/datacenter"): "implemented",
("GET", "/rest/vcenter/cluster"): "implemented",
("GET", "/rest/appliance/system/version"): "implemented",
}
@lru_cache(maxsize=1)
def _universe_routes() -> dict[tuple[str, str], str]:
path = Path(__file__).with_name("universe.json")
if not path.is_file():
return {}
payload = json.loads(path.read_text(encoding="utf-8"))
out: dict[tuple[str, str], str] = {}
for entry in payload.get("methods") or []:
verb = str(entry.get("verb", "")).upper()
route = str(entry.get("path", ""))
if verb and route:
out[(verb, route)] = "stub"
return out
def _merged() -> dict[tuple[str, str], str]:
merged = dict(_universe_routes())
merged.update(CORE_IMPLEMENTED)
return merged
IMPLEMENTED: dict[tuple[str, str], str] = _merged()
def reload_coverage() -> dict[tuple[str, str], str]:
"""Reload universe.json (tests / generator)."""
_universe_routes.cache_clear()
IMPLEMENTED.clear()
IMPLEMENTED.update(_merged())
from contextlib import suppress
with suppress(Exception):
from app.vsphere.contracts.matrix import _compiled_routes
_compiled_routes.cache_clear()
return IMPLEMENTED
def catalog_entries() -> list[dict[str, str]]:
return [
{"verb": verb, "path": path, "status": status}
for (verb, path), status in sorted(IMPLEMENTED.items())
]
def is_implemented(verb: str, path: str) -> bool:
return IMPLEMENTED.get((verb.upper(), path)) in ACTIVE_STATUSES
def universe_stats() -> dict[str, int | str]:
path = Path(__file__).with_name("universe.json")
if not path.is_file():
return {"broadcom_operations": 0, "unique_routes": 0}
payload = json.loads(path.read_text(encoding="utf-8"))
return {
"source_label": str(payload.get("source_label") or ""),
"broadcom_operations": int(payload.get("broadcom_operations") or 0),
"unique_routes": int(payload.get("unique_routes") or 0),
"registry_methods": len(IMPLEMENTED),
"core_methods": len(CORE_IMPLEMENTED),
"stub_methods": sum(1 for status in IMPLEMENTED.values() if status == "stub"),
}
+213
View File
@@ -0,0 +1,213 @@
"""Inventory CRUD, host maintenance, datastore files, DVS."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Response
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere import inventory
from app.vsphere.domain import content, inventory_ops
from app.vsphere.errors import invalid_argument
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Inventory Ext"])
def _require_name(body: dict[str, Any]) -> str:
name = body.get("name")
if not name:
raise invalid_argument("name is required")
return str(name)
@router.post("/api/vcenter/datacenter")
async def create_dc(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Datacenter.Create")),
) -> str:
return await inventory_ops.create_datacenter(
database, name=_require_name(body), folder=str(body.get("folder") or "group-d1")
)
@router.delete("/api/vcenter/datacenter/{datacenter}")
async def delete_dc(
datacenter: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Datacenter.Delete")),
) -> Response:
await inventory_ops.delete_managed(database, datacenter)
return Response(status_code=204)
@router.post("/api/vcenter/cluster")
async def create_cluster(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Cluster.Create")),
) -> str:
return await inventory_ops.create_cluster(
database,
name=_require_name(body),
folder=str(body.get("folder") or "group-h23"),
drs_enabled=bool((body.get("drs") or {}).get("enabled", True)),
ha_enabled=bool((body.get("ha") or {}).get("enabled", True)),
)
@router.delete("/api/vcenter/cluster/{cluster}")
async def delete_cluster(
cluster: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Cluster.Delete")),
) -> Response:
await inventory_ops.delete_managed(database, cluster)
return Response(status_code=204)
@router.post("/api/vcenter/folder")
async def create_folder(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Folder.Create")),
) -> str:
return await inventory_ops.create_folder(
database,
name=_require_name(body),
parent=str(body.get("parent") or body.get("folder") or "group-v23"),
folder_type=str(body.get("type") or "VIRTUAL_MACHINE"),
)
@router.post("/api/vcenter/folder/{folder}")
async def folder_action(
folder: str,
action: str = Query(...),
body: dict[str, Any] | None = None,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Folder.Rename")),
) -> Response:
body = body or {}
if action == "rename":
await inventory_ops.rename_object(database, folder, _require_name(body))
elif action == "move":
parent = body.get("parent")
if not parent:
raise invalid_argument("parent is required")
await inventory_ops.move_object(database, folder, str(parent))
else:
raise invalid_argument(f"unsupported action {action}")
return Response(status_code=204)
@router.delete("/api/vcenter/folder/{folder}")
async def delete_folder(
folder: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Folder.Delete")),
) -> Response:
await inventory_ops.delete_managed(database, folder)
return Response(status_code=204)
@router.post("/api/vcenter/resource-pool")
async def create_rp(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Resource.CreatePool")),
) -> str:
return await inventory_ops.create_resource_pool(
database, name=_require_name(body), parent=str(body.get("parent") or "resgroup-22")
)
@router.delete("/api/vcenter/resource-pool/{resource_pool}")
async def delete_rp(
resource_pool: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Resource.DeletePool")),
) -> Response:
await inventory_ops.delete_managed(database, resource_pool)
return Response(status_code=204)
@router.post("/api/vcenter/host/{host}/maintenance")
async def host_maintenance(
host: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Host.Config.Maintenance")),
) -> dict[str, Any]:
if action not in {"enter", "exit"}:
raise invalid_argument("action must be enter|exit")
return await inventory_ops.set_host_maintenance(database, host, enabled=(action == "enter"))
@router.get("/api/vcenter/datastore/{datastore}/files")
async def ds_files(
datastore: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
return await content.list_datastore_files(database, datastore)
@router.post("/api/vcenter/datastore/{datastore}/files")
async def ds_put_file(
datastore: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Datastore.FileManagement")),
) -> Response:
path = body.get("path")
if not path:
raise invalid_argument("path is required")
await content.put_datastore_file(
database,
datastore,
str(path),
size=int(body.get("size") or 0),
file_type=str(body.get("type") or "FILE"),
)
return Response(status_code=204)
@router.get("/api/vcenter/network/dvs")
async def list_dvs(
database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="VmwareDistributedVirtualSwitch")
return [{"dvs": o.moid, "name": o.name} for o in objects]
@router.post("/api/vcenter/network/dvs")
async def create_dvs(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Network.Assign")),
) -> str:
return await inventory_ops.create_dvs(
database, name=_require_name(body), folder=str(body.get("folder") or "group-n23")
)
@router.post("/api/vcenter/network/dvpg")
async def create_dvpg(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("Network.Assign")),
) -> str:
dvs = body.get("dvs")
if not dvs:
raise invalid_argument("dvs is required")
return await inventory_ops.create_dvpg(
database,
name=_require_name(body),
dvs=str(dvs),
vlan_id=int(body.get("vlan_id") or 0),
)
+223
View File
@@ -0,0 +1,223 @@
"""Legacy `/rest/...` JSON wrappers mirroring Automation API `/api` paths."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import vm_ops
from app.vsphere.errors import invalid_argument
from app.vsphere.rest import router as core
from app.vsphere.rest import tagging_rest
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Legacy REST"])
def _value(payload: Any) -> dict[str, Any]:
return {"value": payload}
@router.get("/rest/vcenter/vm")
async def rest_list_vms(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
names: list[str] | None = Query(default=None),
power_states: list[str] | None = Query(default=None),
hosts: list[str] | None = Query(default=None),
folders: list[str] | None = Query(default=None),
limit: int | None = Query(default=None, ge=1, le=5000),
cursor: int = Query(default=0, ge=0),
) -> dict[str, Any]:
payload = await core.list_vms(
database=database,
_=session,
names=names,
power_states=power_states,
hosts=hosts,
folders=folders,
datacenters=None,
clusters=None,
resource_pools=None,
limit=limit,
cursor=cursor,
)
return _value(payload)
@router.get("/rest/vcenter/vm/{vm}")
async def rest_get_vm(
vm: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.get_vm(vm=vm, database=database, _=session))
@router.get("/rest/vcenter/host")
async def rest_list_hosts(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_hosts(database=database, _=session))
@router.get("/rest/vcenter/datastore")
async def rest_list_datastores(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_datastores(database=database, _=session))
@router.get("/rest/vcenter/network")
async def rest_list_networks(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_networks(database=database, _=session))
@router.get("/rest/vcenter/datacenter")
async def rest_list_datacenters(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_datacenters(database=database, _=session))
@router.get("/rest/vcenter/cluster")
async def rest_list_clusters(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.list_clusters(database=database, _=session))
@router.get("/rest/appliance/system/version")
async def rest_appliance_version(
request: Request,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await core.appliance_version(request=request, database=database))
@router.post("/rest/vcenter/vm/{vm}/power")
async def rest_power_vm(
vm: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.PowerOn")),
) -> dict[str, Any]:
task_id = await vm_ops.set_power(database, vm, action.lower())
return _value({"task": task_id})
# --- CIS tagging (govmomi / terraform-provider-vsphere / pulumi-vsphere) ---
@router.get("/rest/com/vmware/cis/tagging/category")
async def rest_list_categories(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await tagging_rest.list_categories(database=database, _=session))
@router.post("/rest/com/vmware/cis/tagging/category")
async def rest_category_action(
body: dict[str, Any] | None = None,
action: str | None = Query(default=None, alias="~action"),
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateCategory")),
) -> Any:
payload = body or {}
act = (action or "create").lower()
if act == "create":
return _value(
await tagging_rest.create_category(body=payload, database=database, _=session)
)
raise invalid_argument(f"unsupported ~action {act}")
@router.get("/rest/com/vmware/cis/tagging/category/{category_id}")
async def rest_get_category(
category_id: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(
await tagging_rest.get_category(category_id=category_id, database=database, _=session)
)
@router.delete("/rest/com/vmware/cis/tagging/category/{category_id}")
async def rest_delete_category(
category_id: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateCategory")),
) -> Response:
return await tagging_rest.delete_category(category_id=category_id, database=database, _=session)
@router.get("/rest/com/vmware/cis/tagging/tag")
async def rest_list_tags(
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await tagging_rest.list_tags(database=database, _=session))
@router.post("/rest/com/vmware/cis/tagging/tag")
async def rest_tag_action(
body: dict[str, Any] | None = None,
action: str | None = Query(default=None, alias="~action"),
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateTag")),
) -> Any:
payload = body or {}
act = (action or "create").lower()
if act == "create":
return _value(await tagging_rest.create_tag(body=payload, database=database, _=session))
raise invalid_argument(f"unsupported ~action {act}")
@router.get("/rest/com/vmware/cis/tagging/tag/{tag_id}")
async def rest_get_tag(
tag_id: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return _value(await tagging_rest.get_tag(tag_id=tag_id, database=database, _=session))
@router.delete("/rest/com/vmware/cis/tagging/tag/{tag_id}")
async def rest_delete_tag(
tag_id: str,
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateTag")),
) -> Response:
return await tagging_rest.delete_tag(tag_id=tag_id, database=database, _=session)
@router.post("/rest/com/vmware/cis/tagging/tag-association")
async def rest_tag_association(
body: dict[str, Any] | None = None,
action: str | None = Query(default=None, alias="~action"),
database: Database = Depends(get_database),
session: SessionInfo = Depends(require_privilege("InventoryService.Tagging.AttachTag")),
) -> Any:
"""govmomi/terraform use ``?~action=`` instead of JSON ``action``."""
payload = dict(body or {})
if action and "action" not in payload:
payload["action"] = action
result = await tagging_rest.tag_association(body=payload, database=database, _=session)
if isinstance(result, Response):
return result
return _value(result)
+122
View File
@@ -0,0 +1,122 @@
"""Map inventory rows to vSphere Automation REST JSON shapes."""
from __future__ import annotations
from typing import Any
from app.vsphere.inventory import ManagedObject
def vm_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"vm": obj.moid,
"name": obj.name,
"power_state": props.get("power_state", "POWERED_OFF"),
"cpu_count": props.get("cpu_count"),
"memory_size_MiB": props.get("memory_size_mib"),
}
def vm_info(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
cpu = dict(props.get("cpu") or {})
return {
"guest_OS": props.get("guest_OS"),
"name": obj.name,
"identity": props.get("identity"),
"power_state": props.get("power_state"),
"hardware": {
"version": props.get("hardware_version"),
"cpu": {
"count": props.get("cpu_count"),
"cores_per_socket": cpu.get("cores_per_socket"),
},
"memory": {"size_MiB": props.get("memory_size_mib")},
},
"nics": props.get("nics") or [],
"disks": props.get("disks") or [],
"boot": props.get("boot") or {},
}
def host_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"host": obj.moid,
"name": obj.name,
"connection_state": props.get("connection_state", "CONNECTED"),
"power_state": props.get("power_state", "POWERED_ON"),
}
def host_info(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"name": obj.name,
"connection_state": props.get("connection_state", "CONNECTED"),
"power_state": props.get("power_state", "POWERED_ON"),
"cpu": {
"count": props.get("cpu_cores", 1),
"mhz": props.get("cpu_mhz", 2000),
},
"memory": {"size_MiB": props.get("memory_size_mib", 8192)},
}
def datastore_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"datastore": obj.moid,
"name": obj.name,
"type": props.get("type", "VMFS"),
"free_space": props.get("free_space", 0),
"capacity": props.get("capacity", 0),
}
def network_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"network": obj.moid,
"name": obj.name,
"type": props.get("type", "STANDARD_PORTGROUP"),
}
def datacenter_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"datacenter": obj.moid,
"name": obj.name,
"datastore_folder": props.get("datastore_folder"),
"host_folder": props.get("host_folder"),
"network_folder": props.get("network_folder"),
"vm_folder": props.get("vm_folder"),
}
def cluster_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"cluster": obj.moid,
"name": obj.name,
"drs_enabled": props.get("drs_enabled", False),
"ha_enabled": props.get("ha_enabled", False),
}
def folder_summary(obj: ManagedObject) -> dict[str, Any]:
props = obj.props
return {
"folder": obj.moid,
"name": obj.name,
"type": props.get("folder_type", "VIRTUAL_MACHINE"),
}
def resource_pool_summary(obj: ManagedObject) -> dict[str, Any]:
return {
"resource_pool": obj.moid,
"name": obj.name,
}
+80
View File
@@ -0,0 +1,80 @@
"""HttpNfcLease-compatible upload endpoints for OVF/VMDK lab transfers."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import platform_surface
from app.vsphere.errors import not_found
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere NFC"])
@router.get("/nfc/{lease_id}")
async def nfc_lease_status(
lease_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
lease = await platform_surface.get_nfc_lease(database, lease_id)
if lease is None:
raise not_found(f"NFC lease {lease_id} not found")
return lease
@router.post("/nfc/{lease_id}/complete")
async def nfc_lease_complete(
lease_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.DeployTemplate")),
) -> dict[str, Any]:
lease = await platform_surface.complete_nfc_lease(database, lease_id)
if lease is None:
raise not_found(f"NFC lease {lease_id} not found")
return lease
@router.put("/nfc/{lease_id}/files/{filename:path}")
@router.post("/nfc/{lease_id}/files/{filename:path}")
async def nfc_upload_file(
lease_id: str,
filename: str,
request: Request,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.DeployTemplate")),
) -> JSONResponse:
body = await request.body()
lease = await platform_surface.upload_nfc_file(database, lease_id, filename, len(body))
if lease is None:
raise not_found(f"NFC lease {lease_id} not found")
return JSONResponse(
content={
"lease": lease_id,
"filename": filename,
"bytes": len(body),
"state": lease.get("state"),
"transferProgress": lease.get("transferProgress"),
},
status_code=200,
)
@router.get("/nfc/{lease_id}/files/{filename:path}")
async def nfc_download_file(
lease_id: str,
filename: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> Response:
lease = await platform_surface.get_nfc_lease(database, lease_id)
if lease is None:
raise not_found(f"NFC lease {lease_id} not found")
content = f"# lab nfc placeholder for {filename} on {lease_id}\n".encode()
return Response(content=content, media_type="application/octet-stream")
+531
View File
@@ -0,0 +1,531 @@
"""Permissions, identity, guest extras, host detail, console tickets."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Response
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere import inventory
from app.vsphere.domain import vm_ops
from app.vsphere.errors import invalid_argument, not_found
from app.vsphere.security.authz import require_admin, require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Platform"])
@router.get("/api/vcenter/privilege")
async def list_privileges(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, str]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/privilege")
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/authorization/roles")
async def list_roles(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/authorization/roles")
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/authorization/permissions")
async def list_permissions(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, principal, role, entity_moid, propagate FROM vsphere_permissions ORDER BY id"
)
return [
{
"id": row["id"],
"principal": row["principal"],
"role": row["role"],
"entity": row["entity_moid"],
"propagate": row["propagate"],
}
for row in rows
]
@router.post("/api/vcenter/authorization/permissions")
async def create_permission(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
principal = str(body.get("principal") or "").strip()
role = str(body.get("role") or "").strip()
if not principal or not role:
raise invalid_argument("principal and role are required")
entity = body.get("entity") or body.get("entity_moid")
propagate = bool(body.get("propagate", True))
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO vsphere_permissions (principal, role, entity_moid, propagate)
VALUES ($1, $2, $3, $4)
RETURNING id
""",
principal,
role,
None if entity in (None, "", "null") else str(entity),
propagate,
)
return {"id": row["id"]}
@router.delete("/api/vcenter/authorization/permissions/{permission_id}")
async def delete_permission(
permission_id: int,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> Response:
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM vsphere_permissions WHERE id = $1",
permission_id,
)
if not result.endswith("1"):
raise not_found(f"Permission {permission_id} not found")
return Response(status_code=204)
@router.get("/api/vcenter/namespaces")
async def list_namespaces(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/namespaces")
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/namespace-management/nsx-tier0-gateway")
async def nsx_tier0_gateway(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
path = "/api/vcenter/namespace-management/nsx-tier0-gateway"
payload = await api_state.get_payload(database, "GET", path)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/namespace-management/networks")
async def wcp_networks(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
path = "/api/vcenter/namespace-management/networks"
payload = await api_state.get_payload(database, "GET", path)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/namespace-management/virtual-machine-classes")
async def vm_classes(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
path = "/api/vcenter/namespace-management/virtual-machine-classes"
payload = await api_state.get_payload(database, "GET", path)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/identity/providers")
async def identity_providers(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import platform_surface
return await platform_surface.list_identity_providers(database)
@router.post("/api/vcenter/identity/providers")
async def create_identity_provider(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
from app.vsphere.domain import platform_surface
return await platform_surface.upsert_identity_provider(database, body)
@router.get("/api/vcenter/identity/providers/{provider}")
async def get_identity_provider(
provider: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
from app.vsphere.domain import platform_surface
item = await platform_surface.get_identity_provider(database, provider)
if item is not None:
return item
raise not_found(f"Identity provider {provider} not found")
@router.patch("/api/vcenter/identity/providers/{provider}")
@router.put("/api/vcenter/identity/providers/{provider}")
async def update_identity_provider(
provider: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
from app.vsphere.domain import platform_surface
payload = {**body, "provider": provider}
return await platform_surface.upsert_identity_provider(database, payload)
@router.delete("/api/vcenter/identity/providers/{provider}")
async def delete_identity_provider(
provider: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> Response:
from app.vsphere.domain import platform_surface
if not await platform_surface.delete_identity_provider(database, provider):
raise not_found(f"Identity provider {provider} not found")
return Response(status_code=204)
@router.get("/api/vcenter/certificate-management/vcenter/tls")
async def machine_tls(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(
database, "GET", "/api/vcenter/certificate-management/vcenter/tls"
)
return payload if isinstance(payload, dict) else {}
@router.get("/api/vcenter/certificate-management/vcenter/tls-csr")
async def machine_tls_csr_get(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(
database, "GET", "/api/vcenter/certificate-management/vcenter/tls-csr"
)
return payload if isinstance(payload, dict) else {}
@router.post("/api/vcenter/certificate-management/vcenter/tls-csr")
async def machine_tls_csr_create(
body: dict[str, Any] | None = None,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_admin),
) -> dict[str, Any]:
from app.vsphere.domain import api_state
del body
payload = await api_state.get_payload(
database, "GET", "/api/vcenter/certificate-management/vcenter/tls-csr"
)
return payload if isinstance(payload, dict) else {}
@router.get("/api/vcenter/certificate-management/vcenter/trusted-root-chains")
async def trusted_root_chains(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(
database, "GET", "/api/vcenter/certificate-management/vcenter/trusted-root-chains"
)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/vm/{vm}/guest/customization")
async def guest_customization_get(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
customization = obj.props.get("customization")
return customization if isinstance(customization, dict) else {}
@router.get("/api/vcenter/vm/{vm}/guest/networking")
async def guest_networking(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await vm_ops.guest_networking(database, vm)
@router.get("/api/vcenter/vm/{vm}/guest/power")
async def guest_power_get(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, str]:
obj = await vm_ops.require_vm(database, vm)
state = str(obj.props.get("power_state") or "POWERED_OFF")
return {"state": "RUNNING" if state == "POWERED_ON" else "NOT_RUNNING"}
@router.post("/api/vcenter/vm/{vm}/guest/power")
async def guest_power_post(
vm: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.PowerOn")),
) -> Response:
mapping = {"reboot": "reset", "shutdown": "stop", "standby": "suspend"}
await vm_ops.set_power(database, vm, mapping.get(action, action))
return Response(status_code=204)
@router.post("/api/vcenter/vm/{vm}/tools")
async def tools_upgrade(
vm: str,
action: str = Query("upgrade"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.DeviceConnection")),
) -> dict[str, str]:
del action
await vm_ops.require_vm(database, vm)
from app.vsphere.domain import tasks as task_store
task_id = await task_store.create_task(
database,
description=f"Upgrade tools {vm}",
service="com.vmware.vcenter.vm.tools",
operation="upgrade",
result={"vm": vm},
)
return {"task": task_id}
@router.post("/api/vcenter/vm/{vm}/console/tickets")
async def console_tickets(
vm: str,
body: dict[str, Any] | None = None,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.ConsoleInteract")),
) -> dict[str, Any]:
del body
return await vm_ops.console_ticket(database, vm)
@router.post("/api/vcenter/vm/{vm}/guest/customization")
async def guest_customization(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.Rename")),
) -> dict[str, str]:
obj = await vm_ops.require_vm(database, vm)
props = dict(obj.props)
props["customization"] = body
await inventory.update_props(database, vm, props)
from app.vsphere.domain import tasks as task_store
task_id = await task_store.create_task(
database,
description=f"Customize {vm}",
service="com.vmware.vcenter.vm.guest.customization",
operation="set",
result={"vm": vm},
)
return {"task": task_id}
@router.get("/api/vcenter/vm/{vm}/guest/local-filesystem")
async def guest_local_filesystem(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
filesystems = obj.props.get("guest_filesystems")
return filesystems if isinstance(filesystems, dict) else {}
@router.get("/api/vcenter/vm/{vm}/guest/filesystem")
async def guest_filesystem_get(
vm: str,
path: str = Query("/"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
if path in {"/", "/tmp", "/etc"}:
entries = await vm_ops.guest_list_files(database, vm, path)
return {"path": path, "type": "DIRECTORY", "files": entries}
content = await vm_ops.guest_read_file(database, vm, path)
return {"path": path, "type": "FILE", "content": content, "size": len(content.encode("utf-8"))}
@router.put("/api/vcenter/vm/{vm}/guest/filesystem")
async def guest_filesystem_put(
vm: str,
body: dict[str, Any],
path: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.DeviceConnection")),
) -> Response:
content = str(body.get("content") or "")
await vm_ops.guest_write_file(database, vm, path, content, overwrite=True)
return Response(status_code=204)
@router.delete("/api/vcenter/vm/{vm}/guest/filesystem")
async def guest_filesystem_delete(
vm: str,
path: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.DeviceConnection")),
) -> Response:
await vm_ops.guest_delete_file(database, vm, path)
return Response(status_code=204)
@router.get("/api/vcenter/vm/{vm}/guest/filesystem/files")
async def guest_filesystem_list(
vm: str,
path: str = Query("/"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
return await vm_ops.guest_list_files(database, vm, path)
@router.post("/api/vcenter/vm/{vm}")
async def vm_action(
vm: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.MarkAsTemplate")),
) -> dict[str, str]:
if action == "mark-as-template":
task_id = await vm_ops.set_template(database, vm, template=True)
elif action in {"mark-as-vm", "mark-as-virtual-machine"}:
task_id = await vm_ops.set_template(database, vm, template=False)
elif action == "unregister":
task_id = await vm_ops.unregister_vm(database, vm)
else:
raise invalid_argument(f"unsupported action {action}")
return {"task": task_id}
@router.get("/api/vcenter/host/{host}/storage/storage-device")
async def host_storage(
host: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
obj = await inventory.get_object(database, host)
if obj is None or obj.type != "HostSystem":
raise not_found(f"Host {host} not found")
devices = obj.props.get("storage_devices")
return list(devices) if isinstance(devices, list) else []
@router.get("/api/vcenter/host/{host}/networking")
async def host_networking(
host: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, host)
if obj is None or obj.type != "HostSystem":
raise not_found(f"Host {host} not found")
networking = obj.props.get("networking")
return networking if isinstance(networking, dict) else {}
@router.get("/api/vcenter/folder/{folder}/children")
async def folder_children(
folder: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, str]]:
parent = await inventory.get_object(database, folder)
if parent is None:
raise not_found(f"Folder {folder} not found")
children = [obj for obj in await inventory.list_objects(database) if obj.parent_moid == folder]
return [{"moid": c.moid, "type": c.type, "name": c.name} for c in children]
@router.get("/api/vcenter/storage/policies/{policy}/vm")
async def policy_vms(
policy: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
objects = await inventory.list_objects(database, type_name="VirtualMachine")
if policy in {"policy-default", "default"}:
return [obj.moid for obj in objects]
# Named non-default policies attach to every 10th VM for lab demos.
return [obj.moid for i, obj in enumerate(objects) if i % 10 == 0]
@router.get("/api/vapi/metadata/metamodel/service")
async def metamodel_services(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vapi/metadata/metamodel/service")
return payload if isinstance(payload, list) else []
@router.get("/api/vapi/metadata/authentication/component")
async def authentication_components(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(
database, "GET", "/api/vapi/metadata/authentication/component"
)
return payload if isinstance(payload, list) else []
@router.get("/api/vcenter/activity-history")
async def activity_history(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/activity-history")
return payload if isinstance(payload, list) else []
+450
View File
@@ -0,0 +1,450 @@
"""vSphere Automation REST API (/api + legacy /rest)."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBasicCredentials
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere import inventory
from app.vsphere.domain import vm_ops
from app.vsphere.errors import invalid_argument, not_found, unauthenticated
from app.vsphere.rest import mappers
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import (
SESSION_HEADER,
SessionInfo,
create_session,
delete_session,
ensure_default_credentials,
optional_basic,
require_session,
verify_password,
)
router = APIRouter(tags=["vSphere REST"])
def _session_json(session_id: str, *, legacy: bool = False) -> JSONResponse:
# Modern /api/session returns a JSON string; legacy /rest returns {value: ...}.
body: Any = {"value": session_id} if legacy else session_id
return JSONResponse(content=body, status_code=201 if not legacy else 200)
def _attach_session_cookie(response: Response, session_id: str) -> None:
response.headers[SESSION_HEADER] = session_id
response.set_cookie(
key=SESSION_HEADER,
value=session_id,
httponly=False,
samesite="strict",
path="/",
max_age=(2 * 60 * 60),
)
@router.post("/api/session")
async def create_api_session(
request: Request,
database: Database = Depends(get_database),
credentials: HTTPBasicCredentials | None = Depends(optional_basic),
) -> Response:
await ensure_default_credentials(database)
username, password = await _credentials_from_request(request, credentials)
if not await verify_password(database, username, password):
raise unauthenticated("Invalid credentials")
session_id = await create_session(database, username)
response = _session_json(session_id)
_attach_session_cookie(response, session_id)
return response
@router.delete("/api/session")
async def delete_api_session(
session: SessionInfo = Depends(require_session),
database: Database = Depends(get_database),
) -> Response:
await delete_session(database, session.id)
response = Response(status_code=204)
response.delete_cookie(SESSION_HEADER, path="/")
return response
@router.get("/api/session")
async def get_api_session(session: SessionInfo = Depends(require_session)) -> Response:
# vSphere Automation: validate session with HTTP 200 and empty body.
# Lab helpers expose identity via optional headers for the web console.
response = Response(status_code=200)
response.headers["x-vmware-session-user"] = session.username
response.headers["x-vmware-session-roles"] = ",".join(session.roles)
return response
@router.post("/rest/com/vmware/cis/session")
async def create_legacy_session(
request: Request,
database: Database = Depends(get_database),
credentials: HTTPBasicCredentials | None = Depends(optional_basic),
) -> Response:
await ensure_default_credentials(database)
username, password = await _credentials_from_request(request, credentials)
if not await verify_password(database, username, password):
raise unauthenticated("Invalid credentials")
session_id = await create_session(database, username)
response = _session_json(session_id, legacy=True)
_attach_session_cookie(response, session_id)
return response
@router.get("/rest/com/vmware/cis/session")
async def get_legacy_session(session: SessionInfo = Depends(require_session)) -> dict[str, str]:
return {"value": session.id}
@router.delete("/rest/com/vmware/cis/session")
async def delete_legacy_session(
session: SessionInfo = Depends(require_session),
database: Database = Depends(get_database),
) -> dict[str, Any]:
await delete_session(database, session.id)
return {"value": None}
@router.get("/api/appliance/system/version")
async def appliance_version(
request: Request,
database: Database = Depends(get_database),
) -> dict[str, Any]:
"""Lab-friendly: version is readable without a session (real vCenter varies)."""
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/appliance/system/version")
if isinstance(payload, dict) and payload:
# Overlay active runtime major when contract apply changed it.
major = getattr(request.app.state, "runtime_source_version", None)
if major:
out = dict(payload)
out["version"] = str(major)
return out
return payload
return {}
@router.get("/api/vcenter/vm")
async def list_vms(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
names: list[str] | None = Query(default=None),
power_states: list[str] | None = Query(default=None),
hosts: list[str] | None = Query(default=None),
folders: list[str] | None = Query(default=None),
datacenters: list[str] | None = Query(default=None),
clusters: list[str] | None = Query(default=None),
resource_pools: list[str] | None = Query(default=None),
limit: int | None = Query(default=None, ge=1, le=5000),
cursor: int = Query(default=0, ge=0),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="VirtualMachine")
result_objs = objects
if names:
wanted = set(names)
result_objs = [obj for obj in result_objs if obj.name in wanted]
if power_states:
wanted_states = set(power_states)
result_objs = [obj for obj in result_objs if obj.props.get("power_state") in wanted_states]
if hosts:
host_set = set(hosts)
result_objs = [obj for obj in result_objs if obj.props.get("host") in host_set]
if folders:
folder_set = set(folders)
result_objs = [obj for obj in result_objs if obj.parent_moid in folder_set]
if resource_pools:
rp_set = set(resource_pools)
result_objs = [obj for obj in result_objs if obj.props.get("resource_pool") in rp_set]
if clusters or datacenters:
all_objects = {obj.moid: obj for obj in await inventory.list_objects(database)}
if clusters:
cluster_set = set(clusters)
result_objs = [
obj
for obj in result_objs
if all_objects.get(str(obj.props.get("host") or ""), None)
and all_objects[str(obj.props.get("host"))].parent_moid in cluster_set
]
if datacenters:
dc_set = set(datacenters)
filtered = []
for obj in result_objs:
parent = all_objects.get(obj.parent_moid or "")
grand = (
all_objects.get(parent.parent_moid) if parent and parent.parent_moid else None
)
if (parent and parent.moid in dc_set) or (grand and grand.moid in dc_set):
filtered.append(obj)
result_objs = filtered
if limit is not None:
result_objs = result_objs[cursor : cursor + limit]
elif cursor:
result_objs = result_objs[cursor:]
return [mappers.vm_summary(obj) for obj in result_objs]
@router.get("/api/vcenter/vm/{vm}")
async def get_vm(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
return mappers.vm_info(obj)
@router.post("/api/vcenter/vm")
async def create_vm(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Create")),
) -> str:
name = str(body.get("name") or "").strip()
if not name:
raise invalid_argument("name is required")
guest = body.get("guest_OS") or body.get("guest_os") or "OTHER_GUEST_64"
placement = body.get("placement") or {}
folder = placement.get("folder") or "group-v23"
host = placement.get("host") or "host-11"
datastore = placement.get("datastore") or "datastore-31"
pool = placement.get("resource_pool") or placement.get("cluster") or "resgroup-22"
cpu = int((body.get("cpu") or {}).get("count") or body.get("cpu_count") or 1)
memory = int((body.get("memory") or {}).get("size_MiB") or body.get("memory_size_MiB") or 1024)
disks_spec = body.get("disks") or body.get("disk")
nics_spec = body.get("nics") or body.get("ethernet")
networks = body.get("networks") or ["network-41"]
disks = None
if isinstance(disks_spec, list) and disks_spec:
disks = []
for idx, disk in enumerate(disks_spec):
value = disk.get("new_vmdk") or disk.get("value") or disk
capacity = int(value.get("capacity") or value.get("capacity_bytes") or 42949672960)
disks.append(
{
"key": str(2000 + idx),
"value": {
"label": f"Hard disk {idx + 1}",
"capacity": capacity,
"type": "SCSI",
},
}
)
nics = None
if isinstance(nics_spec, list) and nics_spec:
nics = []
for idx, nic in enumerate(nics_spec):
value = nic.get("value") or nic
backing = value.get("backing") or {}
network = str(backing.get("network") or (networks[0] if networks else "network-41"))
nics.append(
{
"key": str(4000 + idx),
"value": {
"label": f"Network adapter {idx + 1}",
"mac": value.get("mac_address") or f"00:50:56:01:00:{idx:02x}",
"state": "NOT_CONNECTED",
"type": value.get("type") or "VMXNET3",
"backing": {"type": "STANDARD_PORTGROUP", "network": network},
},
}
)
if network not in networks:
networks.append(network)
moid, _task = await vm_ops.create_vm(
database,
name=name,
folder=str(folder),
host=str(host),
datastore=str(datastore),
resource_pool=str(pool),
guest_os=str(guest),
cpu_count=cpu,
memory_size_mib=memory,
networks=[str(n) for n in networks],
disks=disks,
nics=nics,
)
return moid
@router.delete("/api/vcenter/vm/{vm}")
async def delete_vm(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Delete")),
) -> Response:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
if obj.props.get("power_state") == "POWERED_ON":
raise invalid_argument("VM must be powered off before delete")
await inventory.delete_object(database, vm)
return Response(status_code=204)
@router.get("/api/vcenter/vm/{vm}/power")
async def get_vm_power(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, str]:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
state = str(obj.props.get("power_state") or "POWERED_OFF")
return {"state": state}
@router.post("/api/vcenter/vm/{vm}/power")
async def power_vm(
vm: str,
action: str = Query(...),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Interact.PowerOn")),
) -> dict[str, str]:
task_id = await vm_ops.set_power(database, vm, action.lower())
return {"task": task_id}
@router.get("/api/vcenter/host")
async def list_hosts(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="HostSystem")
return [mappers.host_summary(obj) for obj in objects]
@router.get("/api/vcenter/host/{host}")
async def get_host(
host: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, host)
if obj is None or obj.type != "HostSystem":
raise not_found(f"Host {host} not found")
return mappers.host_info(obj)
@router.get("/api/vcenter/datastore")
async def list_datastores(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="Datastore")
return [mappers.datastore_summary(obj) for obj in objects]
@router.get("/api/vcenter/datastore/{datastore}")
async def get_datastore(
datastore: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, datastore)
if obj is None or obj.type != "Datastore":
raise not_found(f"Datastore {datastore} not found")
props = obj.props
return {
"name": obj.name,
"type": props.get("type", "VMFS"),
"accessible": props.get("accessible", True),
"free_space": props.get("free_space", 0),
"capacity": props.get("capacity", 0),
"multiple_host_access": props.get("multiple_host_access", True),
}
@router.get("/api/vcenter/network")
async def list_networks(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
networks = await inventory.list_objects(database, type_name="Network")
dvpg = await inventory.list_objects(database, type_name="DistributedVirtualPortgroup")
return [mappers.network_summary(obj) for obj in (*networks, *dvpg)]
@router.get("/api/vcenter/datacenter")
async def list_datacenters(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="Datacenter")
return [mappers.datacenter_summary(obj) for obj in objects]
@router.get("/api/vcenter/cluster")
async def list_clusters(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="ClusterComputeResource")
return [mappers.cluster_summary(obj) for obj in objects]
@router.get("/api/vcenter/folder")
async def list_folders(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="Folder")
return [mappers.folder_summary(obj) for obj in objects]
@router.get("/api/vcenter/resource-pool")
async def list_resource_pools(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
objects = await inventory.list_objects(database, type_name="ResourcePool")
return [mappers.resource_pool_summary(obj) for obj in objects]
@router.get("/api/vcenter/vm/{vm}/guest/identity")
async def vm_guest_identity(
vm: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
return {
"name": obj.name,
"family": "LINUX",
"full_name": {"default_message": obj.props.get("guest_OS", "OTHER")},
"ip_address": "10.0.0." + obj.moid.split("-")[-1],
"host_name": obj.name,
}
async def _credentials_from_request(
request: Request,
credentials: HTTPBasicCredentials | None,
) -> tuple[str, str]:
if credentials is not None:
return credentials.username, credentials.password
# Some clients post JSON credentials (lab convenience).
if request.headers.get("content-type", "").startswith("application/json"):
try:
payload = await request.json()
except Exception:
payload = {}
if isinstance(payload, dict) and payload.get("user_name") and payload.get("password"):
return str(payload["user_name"]), str(payload["password"])
raise unauthenticated("Basic authentication required")
+349
View File
@@ -0,0 +1,349 @@
"""DB-backed per-path routes for Broadcom Automation API stubs without deep handlers.
Each ``(verb, path)`` from the universe registry is registered with
``APIRouter.add_api_route`` (same idea as Proxmox ``register_contract_routes``),
instead of a single ``/api/{full_path:path}`` catch-all.
"""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import JSONResponse
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere import inventory
from app.vsphere.domain import api_state, tagging
from app.vsphere.domain import content as content_domain
from app.vsphere.errors import not_found
from app.vsphere.rest.coverage import IMPLEMENTED
from app.vsphere.security.authz import require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere REST surface"])
_PARAM_RE = re.compile(r"\{([A-Za-z0-9_]+)\}")
def _extract_params(template: str, concrete: str) -> dict[str, str]:
pattern = "^" + _PARAM_RE.sub(r"([^/]+)", template) + "$"
match = re.match(pattern, concrete)
if not match:
return {}
names = _PARAM_RE.findall(template)
return {name: match.group(index + 1) for index, name in enumerate(names)}
async def _live_get(database: Database, template: str, concrete: str) -> Any | None:
"""Return inventory/platform-backed payloads when possible."""
params = _extract_params(template, concrete)
if template == "/api/cis/tagging/category":
return await tagging.list_categories(database)
if template == "/api/cis/tagging/tag":
return await tagging.list_tags(database)
if template == "/api/content/library":
return await content_domain.list_libraries(database)
if template == "/api/content/library/item":
libs = await content_domain.list_libraries(database)
items: list[dict[str, Any]] = []
for lib in libs:
items.extend(await content_domain.list_library_items(database, lib["id"]))
return items
if template == "/api/content/local-library":
libs = await content_domain.list_libraries(database)
return [lib for lib in libs if lib.get("type") == "LOCAL"]
vm = params.get("vm")
if vm and "/hardware/" in template:
obj = await inventory.get_object(database, vm)
if obj is None:
raise not_found(f"VM {vm} not found")
props = obj.props or {}
def _live_list(key: str) -> list[Any] | None:
value = props.get(key)
if isinstance(value, list) and value:
return list(value)
return None
# Prefer non-empty inventory props; otherwise fall through to vsphere_api_state.
if template.endswith("/hardware/cdrom"):
return _live_list("cdroms")
if template.endswith("/hardware/floppy"):
return _live_list("floppies")
if template.endswith("/hardware/serial"):
return _live_list("serials")
if template.endswith("/hardware/parallel"):
return _live_list("parallels")
if template.endswith("/hardware/adapter/scsi"):
return _live_list("scsi_adapters")
if template.endswith("/hardware/adapter/sata"):
return _live_list("sata_adapters")
if template.endswith("/hardware/adapter/nvme"):
return _live_list("nvme_adapters")
if template.endswith("/hardware/boot") and isinstance(props.get("boot"), dict):
return props["boot"]
if template.endswith("/hardware/boot/device"):
return _live_list("boot_devices")
if template.endswith("/hardware/disk"):
return _live_list("disks") or list(props.get("disks") or [])
if template.endswith("/hardware/ethernet"):
return _live_list("nics") or list(props.get("nics") or [])
if "/hardware/disk/" in template and template.endswith("}"):
disk_id = params.get("disk")
for disk in props.get("disks") or []:
if str(disk.get("key") or disk.get("disk")) == str(disk_id):
return disk
if "/hardware/ethernet/" in template and template.endswith("}"):
nic_id = params.get("nic")
for nic in props.get("nics") or []:
if str(nic.get("key") or nic.get("nic")) == str(nic_id):
return nic
if vm and "/guest/" in template:
obj = await inventory.get_object(database, vm)
if obj is None:
raise not_found(f"VM {vm} not found")
props = obj.props or {}
if template.endswith("/guest/local-filesystem"):
if "guest_filesystems" in props:
return props["guest_filesystems"]
return None
identity = props.get("identity") or {}
if identity or props.get("guest_OS") or props.get("guest_ip"):
return {
"name": identity.get("name") or obj.name,
"family": "LINUX"
if "WIN" not in str(props.get("guest_OS", "")).upper()
else "WINDOWS",
"full_name": {"name": props.get("guest_OS") or obj.name},
"host_name": identity.get("name") or obj.name,
"ip_address": props.get("guest_ip"),
}
return None
host = params.get("host")
if host and template.endswith("/networking"):
obj = await inventory.get_object(database, host)
if obj is None:
raise not_found(f"Host {host} not found")
networking = (obj.props or {}).get("networking")
return networking if networking is not None else None
if host and "storage-device" in template:
obj = await inventory.get_object(database, host)
if obj is None:
raise not_found(f"Host {host} not found")
devices = (obj.props or {}).get("storage_devices")
return devices if devices is not None else None
return None
async def _mutate_vm_hardware(
database: Database,
template: str,
concrete: str,
verb: str,
body: dict[str, Any],
) -> Any | None:
params = _extract_params(template, concrete)
vm = params.get("vm")
if not vm or "/hardware/" not in template:
return False
obj = await inventory.get_object(database, vm)
if obj is None:
raise not_found(f"VM {vm} not found")
props = dict(obj.props or {})
if verb == "POST" and template.endswith("/hardware/cdrom"):
items = list(props.get("cdroms") or [])
key = str(3000 + len(items))
items.append(
{"cdrom": key, "label": f"CD/DVD drive {len(items) + 1}", "state": "CONNECTED", **body}
)
props["cdroms"] = items
await inventory.upsert_object(
database,
moid=vm,
type_name=obj.type,
name=obj.name,
parent_moid=obj.parent_moid,
props=props,
)
return key
if verb in {"PUT", "PATCH"} and template.endswith("/hardware/boot"):
props["boot"] = {**(props.get("boot") or {}), **body}
await inventory.upsert_object(
database,
moid=vm,
type_name=obj.type,
name=obj.name,
parent_moid=obj.parent_moid,
props=props,
)
return None
if verb == "DELETE" and "/hardware/cdrom/" in template:
cdrom = params.get("cdrom")
props["cdroms"] = [
c for c in (props.get("cdroms") or []) if str(c.get("cdrom")) != str(cdrom)
]
await inventory.upsert_object(
database,
moid=vm,
type_name=obj.type,
name=obj.name,
parent_moid=obj.parent_moid,
props=props,
)
return None
return False # not handled specialized; fall through to api_state
async def _dispatch(
template: str,
verb: str,
request: Request,
database: Database,
) -> Response:
concrete = request.url.path
method = verb.upper()
raw_body: Any = {}
if method in {"POST", "PUT", "PATCH"}:
try:
raw_body = await request.json()
except Exception:
raw_body = {}
if not isinstance(raw_body, dict):
raw_body = {"value": raw_body}
if method == "GET":
live = await _live_get(database, template, concrete)
if live is not None and not api_state.is_empty_payload(live):
return JSONResponse(content=live, status_code=200)
stored = await api_state.get_payload_or_seed(database, "GET", template)
if stored is None or api_state.is_empty_payload(stored):
return JSONResponse(content={"path": template, "status": "NOT_SEEDED"}, status_code=404)
return JSONResponse(content=stored, status_code=200)
hw = await _mutate_vm_hardware(database, template, concrete, method, raw_body)
if hw is not False:
if isinstance(hw, str):
return JSONResponse(content=hw, status_code=201)
return Response(status_code=204)
if method in {"PUT", "PATCH"}:
existing = await api_state.get_payload(database, "GET", template)
if isinstance(existing, dict) and isinstance(raw_body, dict):
merged = {**existing, **raw_body}
elif isinstance(raw_body, dict) and raw_body:
merged = raw_body
elif existing is not None:
merged = existing
else:
merged = {}
await api_state.put_payload(database, "GET", template, merged)
return JSONResponse(content=merged, status_code=200)
if method == "DELETE":
# Soft-delete: restore seed_payload from DB so lab GETs never go empty/404.
await api_state.restore_seed_payload(database, "GET", template)
if template.endswith("}"):
parent = template.rsplit("/", 1)[0]
if parent:
await api_state.restore_seed_payload(database, "GET", parent)
return Response(status_code=204)
# POST create / action
action = request.query_params.get("action")
if action:
await api_state.put_payload(
database,
"GET",
template,
{
"last_action": action,
"accepted": True,
"path": template,
**({} if not raw_body else {"spec": raw_body}),
},
)
if action.endswith("Task") or "task" in action.lower():
from app.vsphere.domain import tasks as task_store
task_id = await task_store.create_task(
database,
description=f"{action} {template}",
service="com.vmware.vapi",
operation=action,
status="SUCCEEDED",
result={"path": template, "action": action},
)
return JSONResponse(content=task_id, status_code=200)
return JSONResponse(content={"status": "SUCCESS", "action": action}, status_code=200)
new_id = await api_state.new_id(template.rstrip("/").rsplit("/", 1)[-1].strip("{}") or "id")
collection_payload = await api_state.get_payload(database, "GET", template)
created = {"id": new_id, "name": raw_body.get("name") or new_id, **raw_body}
if isinstance(collection_payload, list):
collection_payload = [*collection_payload, created]
await api_state.put_payload(database, "GET", template, collection_payload)
else:
await api_state.put_payload(
database, "GET", f"{template}/{{{template.rsplit('/', 1)[-1]}}}", created
)
await api_state.put_payload(database, "GET", template, created)
return JSONResponse(content=new_id, status_code=201)
def _endpoint(
template: str,
verb: str,
) -> Callable[..., Awaitable[Response]]:
async def dispatch(
request: Request,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> Response:
return await _dispatch(template, verb, request, database)
dispatch.__name__ = f"vsphere_stub_{verb}_{template.replace('/', '_').strip('_')}"
dispatch.__qualname__ = dispatch.__name__
return dispatch
def register_stub_routes(
target: APIRouter | None = None,
*,
implemented: dict[tuple[str, str], str] | None = None,
) -> int:
"""Register one FastAPI route per stub ``(verb, path)`` from the coverage registry.
Deep ``CORE_IMPLEMENTED`` handlers are skipped so they keep winning on their
dedicated routers. Returns the number of routes added.
"""
api = target if target is not None else router
registry = implemented if implemented is not None else IMPLEMENTED
added = 0
for (verb, path), status in sorted(registry.items(), key=lambda item: (item[0][1], item[0][0])):
if status != "stub":
continue
api.add_api_route(
path,
_endpoint(path, verb),
methods=[verb],
name=f"vsphere-stub:{verb}:{path}",
include_in_schema=True,
openapi_extra={"x-vmware-implementation": "stub"},
)
added += 1
return added
register_stub_routes()
+140
View File
@@ -0,0 +1,140 @@
"""CIS tagging REST."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Response
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import tagging
from app.vsphere.errors import invalid_argument
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Tagging"])
@router.get("/api/cis/tagging/category")
async def list_categories(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
return await tagging.list_categories(database)
@router.post("/api/cis/tagging/category")
async def create_category(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateCategory")),
) -> str:
spec = body.get("create_spec") if isinstance(body.get("create_spec"), dict) else body
name = spec.get("name") if isinstance(spec, dict) else None
if not name:
raise invalid_argument("create_spec.name is required")
return await tagging.create_category(
database,
name=str(name),
description=str(spec.get("description") or ""),
cardinality=str(spec.get("cardinality") or "MULTIPLE"),
associable_types=list(spec.get("associable_types") or []),
)
@router.get("/api/cis/tagging/category/{category_id}")
async def get_category(
category_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await tagging.get_category(database, category_id)
@router.delete("/api/cis/tagging/category/{category_id}")
async def delete_category(
category_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateCategory")),
) -> Response:
await tagging.delete_category(database, category_id)
return Response(status_code=204)
@router.get("/api/cis/tagging/tag")
async def list_tags(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[str]:
return await tagging.list_tags(database)
@router.post("/api/cis/tagging/tag")
async def create_tag(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateTag")),
) -> str:
spec = body.get("create_spec") if isinstance(body.get("create_spec"), dict) else body
if not isinstance(spec, dict):
raise invalid_argument("create_spec is required")
category_id = spec.get("category_id")
name = spec.get("name")
if not category_id:
raise invalid_argument("create_spec.category_id is required")
if not name:
raise invalid_argument("create_spec.name is required")
return await tagging.create_tag(
database,
category_id=str(category_id),
name=str(name),
description=str(spec.get("description") or ""),
)
@router.get("/api/cis/tagging/tag/{tag_id}")
async def get_tag(
tag_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
return await tagging.get_tag(database, tag_id)
@router.delete("/api/cis/tagging/tag/{tag_id}")
async def delete_tag(
tag_id: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.CreateTag")),
) -> Response:
await tagging.delete_tag(database, tag_id)
return Response(status_code=204)
@router.post("/api/cis/tagging/tag-association")
async def tag_association(
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("InventoryService.Tagging.AttachTag")),
) -> Any:
action = str(body.get("action") or "attach")
tag_id = body.get("tag_id")
obj = body.get("object_id") or body.get("object") or {}
object_type = str(obj.get("type") or body.get("type") or "VirtualMachine")
object_id = str(obj.get("id") or body.get("id") or "")
if action == "list-attached-tags":
if not object_id:
raise invalid_argument("object_id.id is required")
return await tagging.list_attached_tags(database, object_type, object_id)
if not tag_id:
raise invalid_argument("tag_id is required")
if not object_id:
raise invalid_argument("object_id.id is required")
if action == "attach":
await tagging.attach_tag(database, str(tag_id), object_type, object_id)
return Response(status_code=204)
if action == "detach":
await tagging.detach_tag(database, str(tag_id), object_type, object_id)
return Response(status_code=204)
raise invalid_argument(f"unsupported action {action}")
+47
View File
@@ -0,0 +1,47 @@
"""CIS tasks REST."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import tasks as task_store
from app.vsphere.errors import not_found
from app.vsphere.security.authz import require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere Tasks"])
@router.get("/api/cis/tasks")
async def list_tasks(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
tasks = await task_store.list_tasks(database)
if tasks:
return tasks
# Fresh lab DB — ensure callers always see at least one completed task.
await task_store.create_task(
database,
description="Lab inventory seed",
service="com.vmware.vcenter",
operation="seed",
result={"status": "SUCCEEDED"},
)
return await task_store.list_tasks(database)
@router.get("/api/cis/tasks/{task}")
async def get_task(
task: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
payload = await task_store.get_task(database, task)
if payload is None:
raise not_found(f"Task {task} not found")
return payload
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
"""HTTP middleware: resolve registered Automation API surface (lab: no version 501).
Historically this returned HTTP 501 when the hot-swapped catalog major was below
a path's PATH_FLOOR. That blocked real lab data for Ansible/Terraform/apps.
Catalog majors still filter the UI browse list; runtime always serves registered
routes with deep handlers or DB-backed stubs.
"""
from __future__ import annotations
from collections.abc import Callable
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.vsphere.contracts.matrix import available_for_request
class VsphereVersionGateMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable) -> Response:
path = request.url.path
if not (path.startswith("/api/") or path.startswith("/rest/")):
return await call_next(request)
major = getattr(request.app.state, "vsphere_contract_major", 9)
# Touch resolver so unknown paths still fall through to FastAPI 404 /
# stub catch-all; registered paths are never version-blocked.
available_for_request(request.method, path, int(major))
return await call_next(request)
+227
View File
@@ -0,0 +1,227 @@
"""VM hardware, snapshots, clone, tools — REST extensions."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Response
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.domain import vm_ops
from app.vsphere.errors import invalid_argument
from app.vsphere.rest import mappers
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import SessionInfo
router = APIRouter(tags=["vSphere VM Ext"])
@router.get("/api/vcenter/vm/{vm}/tools")
async def vm_tools(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
tools = obj.props.get("tools")
if isinstance(tools, dict) and tools:
out = dict(tools)
out["run_state"] = (
"RUNNING" if obj.props.get("power_state") == "POWERED_ON" else "NOT_RUNNING"
)
return out
return {}
@router.get("/api/vcenter/vm/{vm}/hardware")
async def vm_hardware(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
return mappers.vm_info(obj)["hardware"]
@router.get("/api/vcenter/vm/{vm}/hardware/cpu")
async def get_cpu(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
cpu = dict(obj.props.get("cpu") or {})
return {
"count": obj.props.get("cpu_count")
if obj.props.get("cpu_count") is not None
else cpu.get("count"),
"cores_per_socket": cpu.get("cores_per_socket"),
"hot_add_enabled": cpu.get("hot_add_enabled"),
}
@router.patch("/api/vcenter/vm/{vm}/hardware/cpu")
async def patch_cpu(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.CPUCount")),
) -> Response:
await vm_ops.update_hardware_cpu(database, vm, int(body.get("count") or 1))
return Response(status_code=204)
@router.get("/api/vcenter/vm/{vm}/hardware/memory")
async def get_memory(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
memory = dict(obj.props.get("memory") or {})
return {
"size_MiB": obj.props.get("memory_size_mib")
if obj.props.get("memory_size_mib") is not None
else memory.get("size_MiB"),
"hot_add_enabled": memory.get("hot_add_enabled"),
}
@router.patch("/api/vcenter/vm/{vm}/hardware/memory")
async def patch_memory(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.Memory")),
) -> Response:
await vm_ops.update_hardware_memory(
database, vm, int(body.get("size_MiB") or body.get("size_mib") or 1024)
)
return Response(status_code=204)
@router.get("/api/vcenter/vm/{vm}/hardware/disk")
async def list_disks(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[dict[str, Any]]:
obj = await vm_ops.require_vm(database, vm)
return list(obj.props.get("disks") or [])
@router.post("/api/vcenter/vm/{vm}/hardware/disk")
async def create_disk(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.AddNewDisk")),
) -> str:
capacity = int(
(body.get("new_vmdk") or {}).get("capacity") or body.get("capacity") or 10737418240
)
disk = await vm_ops.add_disk(database, vm, capacity)
return str(disk["key"])
@router.get("/api/vcenter/vm/{vm}/hardware/ethernet")
async def list_nics(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[dict[str, Any]]:
obj = await vm_ops.require_vm(database, vm)
return list(obj.props.get("nics") or [])
@router.post("/api/vcenter/vm/{vm}/hardware/ethernet")
async def create_nic(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Config.AddRemoveDevice")),
) -> str:
network = ((body.get("backing") or {}).get("network")) or body.get("network") or "network-41"
nic = await vm_ops.add_nic(database, vm, str(network))
return str(nic["key"])
@router.get("/api/vcenter/vm/{vm}/hardware/boot")
async def get_boot(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> dict[str, Any]:
obj = await vm_ops.require_vm(database, vm)
boot = obj.props.get("boot")
if isinstance(boot, dict) and boot:
return boot
from app.vsphere.domain import api_state
payload = await api_state.get_payload(database, "GET", "/api/vcenter/vm/{vm}/hardware/boot")
return payload if isinstance(payload, dict) else {}
@router.get("/api/vcenter/vm/{vm}/snapshots")
async def list_snapshots(
vm: str, database: Database = Depends(get_database), _: SessionInfo = Depends(require_read)
) -> list[dict[str, Any]]:
return await vm_ops.list_snapshots(database, vm)
@router.post("/api/vcenter/vm/{vm}/snapshots")
async def create_snapshot(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.State.CreateSnapshot")),
) -> dict[str, str]:
snap_id, task_id = await vm_ops.create_snapshot(
database,
vm,
name=str(body.get("name") or "snapshot"),
description=str(body.get("description") or ""),
memory=bool(body.get("memory")),
)
return {"snapshot": snap_id, "task": task_id}
@router.delete("/api/vcenter/vm/{vm}/snapshots/{snapshot}")
async def delete_snapshot(
vm: str,
snapshot: str,
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.State.RemoveSnapshot")),
) -> dict[str, str]:
task_id = await vm_ops.delete_snapshot(database, vm, snapshot)
return {"task": task_id}
@router.post("/api/vcenter/vm/{vm}/snapshots/{snapshot}")
async def revert_snapshot(
vm: str,
snapshot: str,
action: str = Query("revert"),
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.State.RevertToSnapshot")),
) -> dict[str, str]:
if action != "revert":
raise invalid_argument(f"unsupported action {action}")
task_id = await vm_ops.revert_snapshot(database, vm, snapshot)
return {"task": task_id}
@router.post("/api/vcenter/vm/{vm}/clone")
async def clone_vm(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Provisioning.Clone")),
) -> dict[str, str]:
name = str(body.get("name") or f"{vm}-clone")
folder = (body.get("placement") or {}).get("folder")
moid, task_id = await vm_ops.clone_vm(
database, source_vm=vm, name=name, folder=folder, power_on=bool(body.get("power_on"))
)
return {"vm": moid, "task": task_id}
@router.post("/api/vcenter/vm/{vm}/relocate")
async def relocate_vm(
vm: str,
body: dict[str, Any],
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Move")),
) -> dict[str, str]:
placement = body.get("placement") or body
task_id = await vm_ops.relocate_vm(
database, vm, host=placement.get("host"), datastore=placement.get("datastore")
)
return {"task": task_id}
+1
View File
@@ -0,0 +1 @@
"""vSphere session authentication."""
+179
View File
@@ -0,0 +1,179 @@
"""Role / privilege authorization for vSphere REST (and SOAP gates)."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import Any
from fastapi import Depends
from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere.errors import unauthorized
from app.vsphere.security.session import SessionInfo, require_session
# Privilege catalog (subset of vSphere privilege ids).
PRIVILEGES: dict[str, str] = {
"System.Anonymous": "Anonymous access",
"System.Read": "Read inventory",
"System.View": "View inventory",
"Global.ManageCustomFields": "Manage custom fields",
"Authorization.ModifyPermissions": "Modify permissions",
"VirtualMachine.Inventory.Create": "Create VM",
"VirtualMachine.Inventory.Delete": "Delete VM",
"VirtualMachine.Inventory.Move": "Move VM",
"VirtualMachine.Interact.PowerOn": "Power on VM",
"VirtualMachine.Interact.PowerOff": "Power off VM",
"VirtualMachine.Interact.Suspend": "Suspend VM",
"VirtualMachine.Interact.Reset": "Reset VM",
"VirtualMachine.Interact.DeviceConnection": "Connect devices",
"VirtualMachine.Interact.ConsoleInteract": "Console",
"VirtualMachine.Config.AddNewDisk": "Add disk",
"VirtualMachine.Config.AddExistingDisk": "Add existing disk",
"VirtualMachine.Config.RemoveDisk": "Remove disk",
"VirtualMachine.Config.CPUCount": "Change CPU",
"VirtualMachine.Config.Memory": "Change memory",
"VirtualMachine.Config.AddRemoveDevice": "Add/remove device",
"VirtualMachine.Config.Rename": "Rename VM",
"VirtualMachine.Provisioning.Clone": "Clone VM",
"VirtualMachine.Provisioning.DeployTemplate": "Deploy template",
"VirtualMachine.Provisioning.MarkAsTemplate": "Mark as template",
"VirtualMachine.State.CreateSnapshot": "Create snapshot",
"VirtualMachine.State.RemoveSnapshot": "Remove snapshot",
"VirtualMachine.State.RevertToSnapshot": "Revert snapshot",
"Datastore.Browse": "Browse datastore",
"Datastore.FileManagement": "Manage datastore files",
"Host.Config.Maintenance": "Host maintenance",
"Folder.Create": "Create folder",
"Folder.Delete": "Delete folder",
"Folder.Rename": "Rename folder",
"Folder.Move": "Move folder",
"Datacenter.Create": "Create datacenter",
"Datacenter.Delete": "Delete datacenter",
"Cluster.Create": "Create cluster",
"Cluster.Delete": "Delete cluster",
"Resource.CreatePool": "Create resource pool",
"Resource.DeletePool": "Delete resource pool",
"Network.Assign": "Assign network",
"ContentLibrary.CreateLocalLibrary": "Create content library",
"ContentLibrary.AddLibraryItem": "Add library item",
"InventoryService.Tagging.CreateCategory": "Create tag category",
"InventoryService.Tagging.CreateTag": "Create tag",
"InventoryService.Tagging.AttachTag": "Attach tag",
}
_ALL = frozenset(PRIVILEGES)
_READ = frozenset({"System.Anonymous", "System.Read", "System.View", "Datastore.Browse"})
_POWER = frozenset(
{
*_READ,
"VirtualMachine.Interact.PowerOn",
"VirtualMachine.Interact.PowerOff",
"VirtualMachine.Interact.Suspend",
"VirtualMachine.Interact.Reset",
"VirtualMachine.Interact.ConsoleInteract",
"VirtualMachine.State.CreateSnapshot",
"VirtualMachine.State.RemoveSnapshot",
"VirtualMachine.State.RevertToSnapshot",
"VirtualMachine.Provisioning.Clone",
}
)
_VM_ADMIN = frozenset(
{
*_POWER,
"VirtualMachine.Inventory.Create",
"VirtualMachine.Inventory.Delete",
"VirtualMachine.Inventory.Move",
"VirtualMachine.Config.AddNewDisk",
"VirtualMachine.Config.AddExistingDisk",
"VirtualMachine.Config.RemoveDisk",
"VirtualMachine.Config.CPUCount",
"VirtualMachine.Config.Memory",
"VirtualMachine.Config.AddRemoveDevice",
"VirtualMachine.Config.Rename",
"VirtualMachine.Provisioning.DeployTemplate",
"VirtualMachine.Provisioning.MarkAsTemplate",
"VirtualMachine.Interact.DeviceConnection",
"Datastore.FileManagement",
"Network.Assign",
"InventoryService.Tagging.CreateCategory",
"InventoryService.Tagging.CreateTag",
"InventoryService.Tagging.AttachTag",
"ContentLibrary.CreateLocalLibrary",
"ContentLibrary.AddLibraryItem",
}
)
ROLE_PRIVILEGES: dict[str, frozenset[str]] = {
"Administrator": _ALL,
"ReadOnly": _READ,
"VirtualMachinePowerUser": _POWER,
"VirtualMachineAdministrator": _VM_ADMIN,
}
def privileges_for_roles(roles: list[str] | tuple[str, ...]) -> frozenset[str]:
granted: set[str] = set()
for role in roles:
granted.update(ROLE_PRIVILEGES.get(role, ()))
return frozenset(granted)
def has_privilege(roles: list[str] | tuple[str, ...], privilege: str) -> bool:
granted = privileges_for_roles(roles)
if privilege in granted:
return True
# Wildcard Administrator already has exact set; keep prefix convenience.
return any(p.endswith(".*") and privilege.startswith(p[:-1]) for p in granted)
async def load_roles(database: Database, username: str) -> list[str]:
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT roles FROM vsphere_credentials WHERE username = $1",
username,
)
if row is None:
return ["Administrator"] if username.endswith("@vsphere.local") else ["ReadOnly"]
roles = list(row["roles"] or [])
return roles or ["ReadOnly"]
def require_privilege(*needed: str) -> Callable[..., Any]:
"""FastAPI dependency factory: session must hold every listed privilege."""
async def _dependency(
session: SessionInfo = Depends(require_session),
database: Database = Depends(get_database),
) -> SessionInfo:
roles = list(session.roles)
if not roles:
roles = await load_roles(database, session.username)
for privilege in needed:
if not has_privilege(roles, privilege):
raise unauthorized(f"Missing privilege: {privilege}")
return session
return _dependency
require_read = require_privilege("System.Read")
require_power = require_privilege("VirtualMachine.Interact.PowerOn")
require_vm_mutate = require_privilege("VirtualMachine.Inventory.Create")
require_admin = require_privilege("Authorization.ModifyPermissions")
def guard(*needed: str) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
"""Decorator-style helper for non-FastAPI call sites (SOAP)."""
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
@wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return await fn(*args, **kwargs)
wrapper.__vsphere_privileges__ = needed # type: ignore[attr-defined]
return wrapper
return decorator
+154
View File
@@ -0,0 +1,154 @@
"""Session IDs compatible with vmware-api-session-id header."""
from __future__ import annotations
import secrets
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import Depends, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from app.db.pool import AsyncpgDatabase, Database
from app.dependencies import get_database
from app.security.auth import hash_secret, verify_secret
from app.vsphere.errors import unauthenticated
SESSION_TTL = timedelta(hours=2)
SESSION_HEADER = "vmware-api-session-id"
DEFAULT_USER = "administrator@vsphere.local"
DEFAULT_PASSWORD = "VMware1!"
_basic = HTTPBasic(auto_error=False)
@dataclass(frozen=True, slots=True)
class SessionInfo:
id: str
username: str
roles: tuple[str, ...] = field(default_factory=tuple)
def _pool(database: Database) -> Any:
return database.pool # type: ignore[attr-defined]
async def ensure_default_credentials(database: Database) -> None:
"""Idempotently insert lab SSO credentials (full set via seed preferred)."""
from app.vsphere.profiles import lab_credentials
pool = _pool(database)
async with pool.acquire() as conn:
for cred in lab_credentials():
await conn.execute(
"""
INSERT INTO vsphere_credentials (username, password_hash, roles)
VALUES ($1, $2, $3)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
roles = EXCLUDED.roles
""",
cred.username,
hash_secret(cred.password),
list(cred.roles),
)
async def verify_password(database: Database, username: str, password: str) -> bool:
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT password_hash FROM vsphere_credentials WHERE username = $1",
username,
)
if row is None:
return username == DEFAULT_USER and password == DEFAULT_PASSWORD
return verify_secret(password, str(row["password_hash"]))
async def create_session(database: Database, username: str) -> str:
session_id = secrets.token_hex(16)
expires = datetime.now(UTC) + SESSION_TTL
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO vsphere_sessions (id, username, expires_at)
VALUES ($1, $2, $3)
""",
session_id,
username,
expires,
)
return session_id
async def delete_session(database: Database, session_id: str) -> None:
pool = _pool(database)
async with pool.acquire() as conn:
await conn.execute("DELETE FROM vsphere_sessions WHERE id = $1", session_id)
async def _roles_for(database: Database, username: str) -> tuple[str, ...]:
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT roles FROM vsphere_credentials WHERE username = $1",
username,
)
if row is None:
return ("Administrator",) if username == DEFAULT_USER else ("ReadOnly",)
roles = tuple(str(r) for r in (row["roles"] or []))
return roles or ("ReadOnly",)
async def lookup_session(database: Database, session_id: str) -> SessionInfo | None:
pool = _pool(database)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, username, expires_at FROM vsphere_sessions
WHERE id = $1
""",
session_id,
)
if row is None:
return None
if row["expires_at"] <= datetime.now(UTC):
await conn.execute("DELETE FROM vsphere_sessions WHERE id = $1", session_id)
return None
await conn.execute(
"UPDATE vsphere_sessions SET expires_at = $2 WHERE id = $1",
session_id,
datetime.now(UTC) + SESSION_TTL,
)
username = str(row["username"])
roles = await _roles_for(database, username)
return SessionInfo(id=str(row["id"]), username=username, roles=roles)
async def require_session(
request: Request,
database: Database = Depends(get_database),
) -> SessionInfo:
session_id = request.headers.get(SESSION_HEADER) or request.cookies.get(SESSION_HEADER)
if not session_id:
raise unauthenticated()
info = await lookup_session(database, session_id)
if info is None:
raise unauthenticated("Invalid or expired session")
return info
async def optional_basic(
credentials: HTTPBasicCredentials | None = Depends(_basic),
) -> HTTPBasicCredentials | None:
return credentials
def as_asyncpg(database: Database) -> AsyncpgDatabase:
if not isinstance(database, AsyncpgDatabase):
raise TypeError("vsphere routes require AsyncpgDatabase")
return database
+199
View File
@@ -0,0 +1,199 @@
"""Deterministic vSphere inventory seed for labs."""
from __future__ import annotations
import os
from typing import Any
from app.db.pool import Database
from app.security.auth import hash_secret
from app.vsphere import inventory
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.security.session import ensure_default_credentials
async def _seed_platform(database: Database) -> dict[str, Any]:
"""Libraries/tags/files + full Automation API surface state (all profiles)."""
extras: dict[str, Any] = {}
try:
await seed_platform_extras(database)
extras["platform_extras"] = True
except Exception as error:
extras["platform_extras_error"] = str(error)
try:
extras["api_surface"] = await seed_api_surface(database)
except Exception as error:
extras["api_surface_error"] = str(error)
try:
await seed_appliance_state(database)
extras["appliance"] = True
except Exception as error:
extras["appliance_error"] = str(error)
try:
extras["platform_surface"] = await seed_platform_surface(database)
except Exception as error:
extras["platform_surface_error"] = str(error)
return extras
async def seed_vsphere_inventory(
database: Database,
*,
force: bool = False,
profile: str | None = None,
large_hosts: int | None = None,
large_vms: int | None = None,
) -> dict[str, Any]:
"""Populate vCenter-like inventory from a named profile (default: large / 1000 VMs)."""
resolved = build_vsphere_profile(profile, large_hosts=large_hosts, large_vms=large_vms)
existing = await inventory.count_objects(database)
if existing and not force:
await ensure_default_credentials(database)
platform = await _seed_platform(database)
by_type = await inventory.count_by_type(database)
return {
"seeded": False,
"profile": resolved.name,
"objects": existing,
"by_type": by_type,
"vms": by_type.get("VirtualMachine", 0),
"hosts": by_type.get("HostSystem", 0),
**platform,
}
await _wipe(database)
await _apply_profile(database, resolved)
await ensure_default_credentials(database)
platform = await _seed_platform(database)
by_type = await inventory.count_by_type(database)
return {
"seeded": True,
"profile": resolved.name,
"objects": await inventory.count_objects(database),
"by_type": by_type,
"vms": by_type.get("VirtualMachine", 0),
"hosts": by_type.get("HostSystem", 0),
**platform,
}
async def _wipe(database: Database) -> None:
from app.vsphere.domain.content import clear_transfer_sessions
from app.vsphere.soap.property_collector import clear_pc_state
await clear_pc_state(database)
await clear_transfer_sessions(database)
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
await conn.execute(
"""
DO $$ BEGIN
IF to_regclass('public.vsphere_nfc_leases') IS NOT NULL THEN
DELETE FROM vsphere_nfc_leases;
END IF;
IF to_regclass('public.vsphere_transfer_sessions') IS NOT NULL THEN
DELETE FROM vsphere_transfer_sessions;
END IF;
IF to_regclass('public.vsphere_console_tickets') IS NOT NULL THEN
DELETE FROM vsphere_console_tickets;
END IF;
IF to_regclass('public.vsphere_pc_state') IS NOT NULL THEN
DELETE FROM vsphere_pc_state;
END IF;
END $$;
"""
)
await conn.execute("DELETE FROM vsphere_permissions")
await conn.execute("DELETE FROM vsphere_datastore_files")
await conn.execute("DELETE FROM vsphere_snapshots")
await conn.execute("DELETE FROM vsphere_tag_associations")
await conn.execute("DELETE FROM vsphere_tags")
await conn.execute("DELETE FROM vsphere_tag_categories")
await conn.execute("DELETE FROM vsphere_library_items")
await conn.execute("DELETE FROM vsphere_libraries")
await conn.execute("DELETE FROM vsphere_tasks")
await conn.execute(
"""
DO $$ BEGIN
IF to_regclass('public.vsphere_api_state') IS NOT NULL THEN
DELETE FROM vsphere_api_state;
END IF;
END $$;
"""
)
await conn.execute("UPDATE vsphere_objects SET parent_moid = NULL")
await conn.execute("DELETE FROM vsphere_objects")
async def _apply_profile(database: Database, profile: VsphereSeedProfile) -> None:
rows = [
{
"moid": obj.moid,
"type": obj.type,
"name": obj.name,
"parent_moid": obj.parent_moid,
"props": obj.props,
}
for obj in profile.objects
]
# Parents first: folders → dc → cluster → hosts/vms. Spec order already topological.
await inventory.upsert_objects_batch(database, rows)
pool = database.pool # type: ignore[attr-defined]
async with pool.acquire() as conn:
for cred in profile.credentials:
await conn.execute(
"""
INSERT INTO vsphere_credentials (username, password_hash, roles)
VALUES ($1, $2, $3)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
roles = EXCLUDED.roles
""",
cred.username,
hash_secret(cred.password),
list(cred.roles),
)
for perm in profile.permissions:
await conn.execute(
"""
INSERT INTO vsphere_permissions (principal, role, entity_moid, propagate)
VALUES ($1, $2, $3, $4)
""",
perm.principal,
perm.role,
perm.entity_moid,
perm.propagate,
)
def default_profile_name() -> str:
return os.getenv("SEED_VSPHERE_PROFILE", "large")
async def vsphere_state_summary(database: Database) -> dict[str, Any]:
by_type = await inventory.count_by_type(database)
return {
"hosts": by_type.get("HostSystem", 0),
"vms": by_type.get("VirtualMachine", 0),
"datastores": by_type.get("Datastore", 0),
"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(),
}
__all__ = [
"default_profile_name",
"props_json",
"seed_vsphere_inventory",
"vsphere_state_summary",
]
+5
View File
@@ -0,0 +1,5 @@
"""VIM SOAP /sdk surface."""
from app.vsphere.soap.router import router as vsphere_soap_router
__all__ = ["vsphere_soap_router"]
+92
View File
@@ -0,0 +1,92 @@
"""Minimal PBM (Profile-Based Management) SOAP endpoint for Terraform/govmomi."""
from __future__ import annotations
import re
from xml.sax.saxutils import escape
from fastapi import APIRouter, Request, Response
from fastapi.responses import PlainTextResponse
router = APIRouter(tags=["vSphere PBM"])
NS_SOAP = "http://schemas.xmlsoap.org/soap/envelope/"
@router.get("/pbm")
@router.get("/pbm/")
@router.get("/pbm/sdk")
@router.get("/pbm/sdk/")
async def pbm_get() -> PlainTextResponse:
return PlainTextResponse("VMware PBM SDK simulator — POST SOAP to /pbm/sdk")
@router.post("/pbm")
@router.post("/pbm/")
@router.post("/pbm/sdk")
@router.post("/pbm/sdk/")
async def pbm_post(request: Request) -> Response:
body = (await request.body()).decode("utf-8", errors="replace")
if "PbmRetrieveServiceContent" in body or "RetrieveContent" in body:
xml = _wrap(
"PbmRetrieveServiceContentResponse",
"""<returnval>
<about>
<name>VMware vCenter Profile-Driven Storage Service</name>
<version>2.0</version>
</about>
<sessionManager type="PbmSessionManager">SessionManager</sessionManager>
<capabilityMetadataManager type="PbmCapabilityMetadataManager">CapabilityMetadataManager</capabilityMetadataManager>
<profileManager type="PbmProfileProfileManager">ProfileManager</profileManager>
<complianceManager type="PbmComplianceManager">ComplianceManager</complianceManager>
<placementSolver type="PbmPlacementSolver">PlacementSolver</placementSolver>
</returnval>""",
)
return Response(content=xml, media_type='text/xml; charset="utf-8"')
if "PbmQueryProfile" in body or "PbmQueryDefaultRequirementProfile" in body:
xml = _wrap(
"PbmQueryProfileResponse",
"""<returnval>
<uniqueId>com.vmware.storage.default</uniqueId>
<name>vSAN Default Storage Policy</name>
</returnval>
<returnval>
<uniqueId>policy-thin</uniqueId>
<name>Thin provision</name>
</returnval>""",
)
return Response(content=xml, media_type='text/xml; charset="utf-8"')
if "PbmQueryAssociatedProfile" in body:
# Empty association list (no storage policy) — must use the correct response
# tag so govmomi unmarshals []types.PbmProfileId instead of panicking.
xml = _wrap("PbmQueryAssociatedProfileResponse", "")
return Response(content=xml, media_type='text/xml; charset="utf-8"')
if "PbmRetrieveContent" in body:
xml = _wrap("PbmRetrieveContentResponse", "<returnval></returnval>")
return Response(content=xml, media_type='text/xml; charset="utf-8"')
# Unknown PBM op — empty success so clients continue.
op = "PbmMethod"
match = re.search(r"<(?:\w+:)?([A-Za-z0-9_]+)(?:\s|>)", body)
if match:
op = match.group(1)
return Response(
content=_wrap(f"{op}Response", "<returnval></returnval>"),
media_type='text/xml; charset="utf-8"',
)
def _wrap(response_tag: str, inner: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="{NS_SOAP}">
<soapenv:Body>
<{response_tag} xmlns="urn:pbm">
{inner}
</{response_tag}>
</soapenv:Body>
</soapenv:Envelope>
"""
# silence unused escape import warning by using it in fault helper
def _fault(message: str) -> str:
return f"<faultstring>{escape(message)}</faultstring>"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff