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,380 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Python / Ansible-uri / Terraform-data / Pulumi-style cookbooks against the simulator.
|
||||
|
||||
Uses only ``requests`` so it works inside the Compose ``dev`` image.
|
||||
Terraform/Ansible CLIs are optional — when present they are invoked too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib3
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
BASE = os.environ.get("VSPHERE_BASE", "https://localhost").rstrip("/")
|
||||
USER = os.environ.get("VSPHERE_USER", "administrator@vsphere.local")
|
||||
PASSWORD = os.environ.get("VSPHERE_PASSWORD", "VMware1!")
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _session() -> dict[str, str]:
|
||||
response = requests.post(f"{BASE}/api/session", auth=(USER, PASSWORD), verify=False, timeout=60)
|
||||
response.raise_for_status()
|
||||
return {"vmware-api-session-id": response.json()}
|
||||
|
||||
|
||||
def run_python_lifecycle(headers: dict[str, str]) -> dict[str, str]:
|
||||
created = requests.post(
|
||||
f"{BASE}/api/vcenter/vm",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "cookbook-py-01",
|
||||
"guest_OS": "OTHER_GUEST_64",
|
||||
"placement": {
|
||||
"folder": "group-v23",
|
||||
"host": "host-11",
|
||||
"datastore": "datastore-31",
|
||||
"resource_pool": "resgroup-22",
|
||||
},
|
||||
"cpu": {"count": 1},
|
||||
"memory": {"size_MiB": 512},
|
||||
},
|
||||
verify=False,
|
||||
timeout=60,
|
||||
)
|
||||
created.raise_for_status()
|
||||
vm = created.json()
|
||||
power = requests.post(
|
||||
f"{BASE}/api/vcenter/vm/{vm}/power",
|
||||
params={"action": "start"},
|
||||
headers=headers,
|
||||
verify=False,
|
||||
timeout=60,
|
||||
)
|
||||
power.raise_for_status()
|
||||
assert power.json().get("task")
|
||||
# Platform surfaces previously deferred
|
||||
providers = requests.get(
|
||||
f"{BASE}/api/vcenter/identity/providers", headers=headers, verify=False, timeout=60
|
||||
)
|
||||
providers.raise_for_status()
|
||||
assert any(p.get("type_id") in {"Oidc", "Saml", "LocalOS"} for p in providers.json())
|
||||
nsx = requests.get(
|
||||
f"{BASE}/api/vcenter/namespace-management/nsx-tier0-gateway",
|
||||
headers=headers,
|
||||
verify=False,
|
||||
timeout=60,
|
||||
)
|
||||
nsx.raise_for_status()
|
||||
assert nsx.json()
|
||||
nfc = requests.post(
|
||||
f"{BASE}/sdk",
|
||||
data="""<?xml version="1.0"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soapenv:Body>
|
||||
<ImportVApp_Task xmlns="urn:vim25">
|
||||
<_this type="Folder">group-v23</_this>
|
||||
<name>nfc-import-lab</name>
|
||||
</ImportVApp_Task>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>""",
|
||||
headers={
|
||||
**headers,
|
||||
"Content-Type": "text/xml",
|
||||
"Cookie": f'vmware_soap_session="{headers["vmware-api-session-id"]}"',
|
||||
},
|
||||
verify=False,
|
||||
timeout=60,
|
||||
)
|
||||
nfc.raise_for_status()
|
||||
assert "task-" in nfc.text and "ImportVApp_TaskResponse" in nfc.text
|
||||
requests.post(
|
||||
f"{BASE}/api/vcenter/vm/{vm}/power",
|
||||
params={"action": "stop"},
|
||||
headers=headers,
|
||||
verify=False,
|
||||
timeout=60,
|
||||
).raise_for_status()
|
||||
requests.delete(
|
||||
f"{BASE}/api/vcenter/vm/{vm}", headers=headers, verify=False, timeout=60
|
||||
).raise_for_status()
|
||||
return {"python": "ok", "vm": str(vm)}
|
||||
|
||||
|
||||
def run_ansible_uri(headers: dict[str, str]) -> dict[str, str]:
|
||||
"""Mirror examples/ansible/vsphere_playbook.yml using the same REST calls."""
|
||||
|
||||
created = requests.post(
|
||||
f"{BASE}/api/vcenter/vm",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "cookbook-ansible-01",
|
||||
"guest_OS": "OTHER_GUEST_64",
|
||||
"placement": {
|
||||
"folder": "group-v23",
|
||||
"host": "host-11",
|
||||
"datastore": "datastore-31",
|
||||
"resource_pool": "resgroup-22",
|
||||
},
|
||||
"cpu": {"count": 1},
|
||||
"memory": {"size_MiB": 512},
|
||||
},
|
||||
verify=False,
|
||||
timeout=60,
|
||||
)
|
||||
created.raise_for_status()
|
||||
vm = created.json()
|
||||
for action in ("start", "stop"):
|
||||
requests.post(
|
||||
f"{BASE}/api/vcenter/vm/{vm}/power",
|
||||
params={"action": action},
|
||||
headers=headers,
|
||||
verify=False,
|
||||
timeout=60,
|
||||
).raise_for_status()
|
||||
requests.put(
|
||||
f"{BASE}/api/vcenter/vm/{vm}/guest/filesystem",
|
||||
params={"path": "/tmp/ansible-marker"},
|
||||
headers=headers,
|
||||
json={"content": "ansible-ok"},
|
||||
verify=False,
|
||||
timeout=60,
|
||||
).raise_for_status()
|
||||
requests.delete(
|
||||
f"{BASE}/api/vcenter/vm/{vm}", headers=headers, verify=False, timeout=60
|
||||
).raise_for_status()
|
||||
result = {"ansible_uri": "ok", "vm": str(vm)}
|
||||
playbook = ROOT / "examples" / "ansible" / "vsphere_playbook.yml"
|
||||
if shutil.which("ansible-playbook") and playbook.is_file():
|
||||
# Prefer simulator HTTP inside compose if BASE is internal.
|
||||
env_base = BASE.replace("https://localhost", "https://localhost")
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ansible-playbook",
|
||||
"-i",
|
||||
str(ROOT / "examples" / "ansible" / "inventory.ini"),
|
||||
str(playbook),
|
||||
"-e",
|
||||
f"vsphere_base={env_base}",
|
||||
"-e",
|
||||
"vm_name=cookbook-ansible-cli-01",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
result["ansible_cli"] = "ok" if proc.returncode == 0 else f"failed:{proc.returncode}"
|
||||
if proc.returncode != 0:
|
||||
result["ansible_cli_stderr"] = (proc.stderr or proc.stdout)[-500:]
|
||||
else:
|
||||
result["ansible_cli"] = "skipped"
|
||||
return result
|
||||
|
||||
|
||||
def run_pulumi_style(headers: dict[str, str]) -> dict[str, str]:
|
||||
"""Mirror examples/pulumi/__main__.py REST ComponentResource flow."""
|
||||
|
||||
created = requests.post(
|
||||
f"{BASE}/api/vcenter/vm",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "cookbook-pulumi-01",
|
||||
"guest_OS": "OTHER_GUEST_64",
|
||||
"placement": {
|
||||
"folder": "group-v23",
|
||||
"host": "host-11",
|
||||
"datastore": "datastore-31",
|
||||
"resource_pool": "resgroup-22",
|
||||
},
|
||||
"cpu": {"count": 1},
|
||||
"memory": {"size_MiB": 512},
|
||||
},
|
||||
verify=False,
|
||||
timeout=60,
|
||||
)
|
||||
created.raise_for_status()
|
||||
vm = created.json()
|
||||
power = requests.post(
|
||||
f"{BASE}/api/vcenter/vm/{vm}/power",
|
||||
params={"action": "start"},
|
||||
headers=headers,
|
||||
verify=False,
|
||||
timeout=60,
|
||||
)
|
||||
power.raise_for_status()
|
||||
detail = requests.get(f"{BASE}/api/vcenter/vm/{vm}", headers=headers, verify=False, timeout=60)
|
||||
detail.raise_for_status()
|
||||
requests.post(
|
||||
f"{BASE}/api/vcenter/vm/{vm}/power",
|
||||
params={"action": "stop"},
|
||||
headers=headers,
|
||||
verify=False,
|
||||
timeout=60,
|
||||
).raise_for_status()
|
||||
requests.delete(
|
||||
f"{BASE}/api/vcenter/vm/{vm}", headers=headers, verify=False, timeout=60
|
||||
).raise_for_status()
|
||||
result = {"pulumi_style": "ok", "vm": str(vm), "name": detail.json().get("name")}
|
||||
if shutil.which("pulumi"):
|
||||
result["pulumi_cli"] = "available"
|
||||
else:
|
||||
result["pulumi_cli"] = "skipped"
|
||||
return result
|
||||
|
||||
|
||||
def run_terraform_style(headers: dict[str, str]) -> dict[str, str]:
|
||||
"""Validate the inventory lookups Terraform data sources need (SOAP+REST)."""
|
||||
|
||||
# REST inventory used by many TF plans as complementary checks
|
||||
for path in (
|
||||
"/api/vcenter/datacenter",
|
||||
"/api/vcenter/cluster",
|
||||
"/api/vcenter/datastore",
|
||||
"/api/vcenter/network",
|
||||
"/api/vcenter/vm?names=web-01",
|
||||
):
|
||||
response = requests.get(f"{BASE}{path}", headers=headers, verify=False, timeout=60)
|
||||
response.raise_for_status()
|
||||
assert response.json(), path
|
||||
|
||||
# SOAP FindByInventoryPath + CreateVM (resource path)
|
||||
sid = headers["vmware-api-session-id"]
|
||||
soap_headers = {
|
||||
**headers,
|
||||
"Content-Type": "text/xml",
|
||||
"Cookie": f'vmware_soap_session="{sid}"',
|
||||
}
|
||||
find = requests.post(
|
||||
f"{BASE}/sdk",
|
||||
data="""<?xml version="1.0"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soapenv:Body>
|
||||
<FindByInventoryPath xmlns="urn:vim25">
|
||||
<_this type="SearchIndex">SearchIndex</_this>
|
||||
<inventoryPath>/Datacenters/Datacenter/vm/web-01</inventoryPath>
|
||||
</FindByInventoryPath>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>""",
|
||||
headers=soap_headers,
|
||||
verify=False,
|
||||
timeout=60,
|
||||
)
|
||||
find.raise_for_status()
|
||||
assert "VirtualMachine" in find.text
|
||||
create = requests.post(
|
||||
f"{BASE}/sdk",
|
||||
data="""<?xml version="1.0"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soapenv:Body>
|
||||
<CreateVM_Task xmlns="urn:vim25">
|
||||
<_this type="Folder">group-v23</_this>
|
||||
<config>
|
||||
<name>cookbook-tf-01</name>
|
||||
<guestId>otherGuest64</guestId>
|
||||
<numCPUs>1</numCPUs>
|
||||
<memoryMB>512</memoryMB>
|
||||
<files><vmPathName>[datastore1]</vmPathName></files>
|
||||
</config>
|
||||
<pool type="ResourcePool">resgroup-22</pool>
|
||||
<host type="HostSystem">host-11</host>
|
||||
</CreateVM_Task>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>""",
|
||||
headers=soap_headers,
|
||||
verify=False,
|
||||
timeout=60,
|
||||
)
|
||||
create.raise_for_status()
|
||||
assert "task-" in create.text
|
||||
result = {"terraform_style": "ok"}
|
||||
tf_dir = ROOT / "examples" / "terraform" / "vsphere"
|
||||
tf_bin = shutil.which("terraform") or (
|
||||
str(ROOT / ".tools" / "terraform") if (ROOT / ".tools" / "terraform").is_file() else None
|
||||
)
|
||||
if tf_bin and tf_dir.is_dir():
|
||||
server = BASE.replace("https://", "").replace("http://", "")
|
||||
# Prefer the checked-out example (keeps .terraform providers) when writable.
|
||||
work = tf_dir if (tf_dir / ".terraform").is_dir() else None
|
||||
tmp_ctx = None
|
||||
if work is None:
|
||||
tmp_ctx = tempfile.TemporaryDirectory()
|
||||
work = Path(tmp_ctx.name)
|
||||
for name in ("main.tf", "variables.tf"):
|
||||
(work / name).write_text(
|
||||
(tf_dir / name).read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
env = {
|
||||
**os.environ,
|
||||
"TF_VAR_vsphere_server": server,
|
||||
"TF_VAR_vsphere_user": USER,
|
||||
"TF_VAR_vsphere_password": PASSWORD,
|
||||
"TF_VAR_create_lab_vm": "false",
|
||||
}
|
||||
try:
|
||||
if not (work / ".terraform").is_dir():
|
||||
init = subprocess.run(
|
||||
[tf_bin, "init", "-input=false", "-no-color"],
|
||||
cwd=work,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
check=False,
|
||||
)
|
||||
if init.returncode != 0:
|
||||
result["terraform_cli"] = f"init_failed:{(init.stderr or '')[-300:]}"
|
||||
return result
|
||||
plan = subprocess.run(
|
||||
[tf_bin, "plan", "-input=false", "-no-color", "-detailed-exitcode"],
|
||||
cwd=work,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
check=False,
|
||||
)
|
||||
# 0 = no changes, 2 = changes present — both OK for data sources
|
||||
result["terraform_cli"] = (
|
||||
"ok" if plan.returncode in {0, 2} else f"plan_failed:{plan.returncode}"
|
||||
)
|
||||
if plan.returncode not in {0, 2}:
|
||||
result["terraform_cli_stderr"] = ((plan.stderr or "") + (plan.stdout or ""))[-500:]
|
||||
finally:
|
||||
if tmp_ctx is not None:
|
||||
tmp_ctx.cleanup()
|
||||
else:
|
||||
result["terraform_cli"] = "skipped"
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
headers = _session()
|
||||
report: dict[str, object] = {"base": BASE}
|
||||
failed = False
|
||||
for name, fn in (
|
||||
("python", run_python_lifecycle),
|
||||
("ansible", run_ansible_uri),
|
||||
("pulumi", run_pulumi_style),
|
||||
("terraform", run_terraform_style),
|
||||
):
|
||||
try:
|
||||
report[name] = fn(headers)
|
||||
except Exception as error: # noqa: BLE001
|
||||
report[name] = {"error": str(error)}
|
||||
failed = True
|
||||
print(json.dumps(report, indent=2))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user