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,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
|
||||
Reference in New Issue
Block a user