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