524 lines
17 KiB
Python
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
|