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