"""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