Files
vmware-api-simulator/app/vsphere/domain/vm_ops.py
T

524 lines
17 KiB
Python

"""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)
cpu = dict(obj.props.get("cpu") or {})
if obj.props.get("power_state") == "POWERED_ON" and not cpu.get("hot_add_enabled"):
raise invalid_argument(
"Virtual machine must be powered off to reconfigure CPU count "
"when CPU hot-add is disabled"
)
props = dict(obj.props)
props["cpu_count"] = count
cpu["count"] = count
props["cpu"] = cpu
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)
memory = dict(obj.props.get("memory") or {})
if obj.props.get("power_state") == "POWERED_ON" and not memory.get("hot_add_enabled"):
raise invalid_argument(
"Virtual machine must be powered off to reconfigure memory "
"when memory hot-add is disabled"
)
props = dict(obj.props)
props["memory_size_mib"] = size_mib
memory["size_MiB"] = size_mib
props["memory"] = memory
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