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:
@@ -0,0 +1 @@
|
||||
"""Native vSphere REST + SOAP simulation surface."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Versioned vSphere REST coverage catalogs for the lab console."""
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared business operations for REST and SOAP surfaces."""
|
||||
@@ -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)}"
|
||||
@@ -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 []),
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -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 {}),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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 []
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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 []
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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)
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
"""vSphere session authentication."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""VIM SOAP /sdk surface."""
|
||||
|
||||
from app.vsphere.soap.router import router as vsphere_soap_router
|
||||
|
||||
__all__ = ["vsphere_soap_router"]
|
||||
@@ -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
Reference in New Issue
Block a user