1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/vrtManager/create.py
catborise f004552148 feat(instances): a new VM can get a cloud-init seed with its hostname and SSH keys
Cloud images read their first-boot settings with cloud-init. The panel
served them only over HTTP, which a VM reaches only with the network
and a reverse DNS name for its address. A new VM can now carry them
itself: Cloud-init seed (Advanced) on the create form, or cloud_init in
the API.

The job writes a NoCloud seed, an ISO labelled cidata, into the disk's
directory pool as cidata-<uuid>.iso and puts it in the VM's CD-ROM
drive. Its meta-data holds the VM's UUID as instance-id and its name as
hostname, written as JSON so a VM named 123 or yes keeps a string
hostname; its user-data is what the HTTP datasource serves, with the
creating user's SSH keys. The ISO is built with pycdlib and written
through a libvirt stream, as the NVRAM copy is, now one helper for both.

A seed needs a CD-ROM bus and a directory pool, else the create is
refused. A failed create deletes the seed with the new disk, recovery
names one left behind, and deleting the VM with its disks deletes its
seed, found in the pools by name even when ejected. A clone's drive is
left empty: the seed is the source VM's identity.
2026-10-25 19:57:15 +03:00

379 lines
15 KiB
Python

import contextlib
import io
import json
import string
import pycdlib
from vrtManager import util
from vrtManager.connection import wvmConnect
def nocloud_seed(instance_id, hostname, user_data):
"""A cloud-init NoCloud seed: an ISO labelled cidata with the VM's
meta-data and user-data, read at first boot without the network.
cloud-init runs again only for a new instance-id."""
iso = pycdlib.PyCdlib()
iso.new(interchange_level=3, joliet=3, rock_ridge="1.09", vol_ident="cidata")
# JSON is YAML whose values stay strings: a VM named 123 or yes keeps its name
meta_data = json.dumps({"instance-id": instance_id, "local-hostname": hostname})
files = {"meta-data": meta_data, "user-data": user_data}
for name, text in files.items():
data = text.encode()
iso_name = name.replace("-", "").upper()
iso.add_fp(io.BytesIO(data), len(data), f"/{iso_name}.;1", rr_name=name, joliet_path=f"/{name}")
out = io.BytesIO()
iso.write_fp(out)
iso.close()
return out.getvalue()
def get_rbd_storage_data(stg):
xml = stg.XMLDesc(0)
ceph_user = util.get_xml_path(xml, "/pool/source/auth/@username")
def get_ceph_hosts(doc):
hosts = list()
for host in doc.xpath("/pool/source/host"):
name = host.get('name')
if name:
port = host.get('port')
if port:
hosts.append({"name": name, "port": port})
else:
hosts.append({"name": name})
return hosts
ceph_hosts = util.get_xml_path(xml, func=get_ceph_hosts)
secret_uuid = util.get_xml_path(xml, "/pool/source/auth/secret/@uuid")
return ceph_user, secret_uuid, ceph_hosts
class wvmCreate(wvmConnect):
def get_storages_images(self):
"""
Function return all images on all storages
"""
images = []
storages = self.get_storages(only_actives=True)
for storage in storages:
stg = self.get_storage(storage)
with contextlib.suppress(Exception):
stg.refresh(0)
images.extend(img for img in stg.listVolumes() if not img.lower().endswith(".iso"))
return images
def get_os_type(self):
"""Get guest os type"""
return util.get_xml_path(self.get_cap_xml(), "/capabilities/guest/os_type")
def get_host_arch(self):
"""Get host architecture"""
return util.get_xml_path(self.get_cap_xml(), "/capabilities/host/cpu/arch")
def create_volume(self, storage, name, size, image_format, metadata=False, disk_owner_uid=0, disk_owner_gid=0):
size = int(size) * 1073741824
stg = self.get_storage(storage)
storage_type = util.get_xml_path(stg.XMLDesc(0), "/pool/@type")
if storage_type == "dir":
name += f".{image_format}" if image_format in ("qcow", "qcow2") else ".img"
alloc = 0
else:
image_format = 'raw'
alloc = size
metadata = False
xml = f"""
<volume>
<name>{name}</name>
<capacity>{size}</capacity>
<allocation>{alloc}</allocation>
<target>
<format type='{image_format}'/>
<permissions>
<owner>{disk_owner_uid}</owner>
<group>{disk_owner_gid}</group>
<mode>0644</mode>
<label>virt_image_t</label>
</permissions>
<compat>1.1</compat>
<features>
<lazy_refcounts/>
</features>
</target>
</volume>"""
stg.createXML(xml, metadata)
with contextlib.suppress(Exception):
stg.refresh(0)
vol = stg.storageVolLookupByName(name)
return vol.path()
def get_volume_format_type(self, path):
vol = self.get_volume_by_path(path)
return util.vol_driver_type(util.get_xml_path(vol.XMLDesc(0), "/volume/target/format/@type"))
def get_volume_path(self, volume, pool=None):
storages = [pool] if pool else self.get_storages(only_actives=True)
for storage in storages:
stg = self.get_storage(storage)
if stg.info()[0] != 0:
stg.refresh(0)
for img in stg.listVolumes():
if img == volume:
vol = stg.storageVolLookupByName(img)
return vol.path()
def get_storage_by_vol_path(self, vol_path):
vol = self.get_volume_by_path(vol_path)
return vol.storagePoolLookupByVolume()
def clone_from_template(self, clone, template, storage=None, metadata=False, disk_owner_uid=0, disk_owner_gid=0):
vol = self.get_volume_by_path(template)
stg = self.get_storage(storage) if storage else vol.storagePoolLookupByVolume()
storage_type = util.get_xml_path(stg.XMLDesc(0), "/pool/@type")
format = util.get_xml_path(vol.XMLDesc(0), "/volume/target/format/@type")
if storage_type == "dir":
clone += ".img"
else:
metadata = False
xml = f"""
<volume>
<name>{clone}</name>
<capacity>0</capacity>
<allocation>0</allocation>
<target>
<format type='{format}'/>
<permissions>
<owner>{disk_owner_uid}</owner>
<group>{disk_owner_gid}</group>
<mode>0644</mode>
<label>virt_image_t</label>
</permissions>
<compat>1.1</compat>
<features>
<lazy_refcounts/>
</features>
</target>
</volume>"""
stg.createXMLFrom(xml, vol, metadata)
clone_vol = stg.storageVolLookupByName(clone)
return clone_vol.path()
def _defineXML(self, xml):
self.wvm.defineXML(xml)
def delete_volume(self, path):
vol = self.get_volume_by_path(path)
vol.delete()
def create_seed(self, pool, uuid, hostname, user_data):
"""The VM's NoCloud seed as a volume cidata-<uuid>.iso in the pool; its path."""
seed = nocloud_seed(uuid, hostname, user_data)
return self.upload_volume(self.get_storage(pool), f"cidata-{uuid}.iso", seed).path()
def create_instance(
self,
name,
memory,
vcpu,
vcpu_mode,
uuid,
arch,
machine,
firmware,
volumes,
networks,
nwfilter,
virtio,
listener_addr,
net_model="virtio",
video="vga",
console_pass="random",
mac=None,
qemu_ga=True,
add_cdrom="sata",
add_input="default"
):
"""
Create VM function
"""
caps = self.get_capabilities(arch)
dom_caps = self.get_dom_capabilities(arch, machine)
memory = int(memory) * 1024
xml = f"""
<domain type='{dom_caps["domain"]}'>
<name>{name}</name>
<description>None</description>
<uuid>{uuid}</uuid>
<memory unit='KiB'>{memory}</memory>
<vcpu>{vcpu}</vcpu>"""
if dom_caps["os_support"] == "yes":
xml += f"""<os>
<type arch='{arch}' machine='{machine}'>{caps["os_type"]}</type>"""
xml += """ <boot dev='hd'/>
<boot dev='cdrom'/>
<bootmenu enable='yes'/>"""
if firmware:
if firmware["secure"] == "yes":
xml += """<loader readonly='%s' type='%s' secure='%s'>%s</loader>""" % (
firmware["readonly"],
firmware["type"],
firmware["secure"],
firmware["loader"],
)
if firmware["secure"] == "no":
xml += """<loader readonly='%s' type='%s'>%s</loader>""" % (
firmware["readonly"],
firmware["type"],
firmware["loader"],
)
xml += """</os>"""
if caps["features"]:
xml += """<features>"""
for feat in [x for x in ("acpi", "apic", "pae",) if x in caps["features"]]:
xml += f"""<{feat}/>"""
if firmware.get("secure", "no") == "yes":
xml += """<smm state="on"/>"""
xml += """</features>"""
if vcpu_mode == "host-model":
xml += """<cpu mode='host-model'/>"""
elif vcpu_mode == "host-passthrough":
xml += """<cpu mode='host-passthrough'/>"""
elif vcpu_mode != "":
xml += f"""<cpu mode='custom' match='exact' check='none'>
<model fallback='allow'>{vcpu_mode}</model>"""
xml += """</cpu>"""
xml += """
<clock offset="utc"/>
<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>restart</on_crash>
"""
xml += """<devices>"""
vd_disk_letters = list(string.ascii_lowercase)
fd_disk_letters = list(string.ascii_lowercase)
hd_disk_letters = list(string.ascii_lowercase)
sd_disk_letters = list(string.ascii_lowercase)
def get_letter(bus_type):
if bus_type == "ide":
return hd_disk_letters.pop(0)
elif bus_type in ["sata", "scsi"]:
return sd_disk_letters.pop(0)
elif bus_type == "fdc":
return fd_disk_letters.pop(0)
elif bus_type == "virtio":
return vd_disk_letters.pop(0)
else:
return sd_disk_letters.pop(0)
for volume in volumes:
disk_opts = ""
if volume["cache_mode"] is not None and volume["cache_mode"] != "default":
disk_opts += f"cache='{volume['cache_mode']}' "
if volume["io_mode"] is not None and volume["io_mode"] != "default":
disk_opts += f"io='{volume['io_mode']}' "
if volume["discard_mode"] is not None and volume["discard_mode"] != "default":
disk_opts += f"discard='{volume['discard_mode']}' "
if volume["detect_zeroes_mode"] is not None and volume["detect_zeroes_mode"] != "default":
disk_opts += f"detect_zeroes='{volume['detect_zeroes_mode']}' "
stg = self.get_storage_by_vol_path(volume["path"])
stg_type = util.get_xml_path(stg.XMLDesc(0), "/pool/@type")
if volume["device"] == "cdrom":
add_cdrom = "None"
if stg_type == "rbd":
ceph_user, secret_uuid, ceph_hosts = get_rbd_storage_data(stg)
xml += f"""<disk type='network' device='disk'>
<driver name='qemu' type='{volume["type"]}' {disk_opts} />"""
xml += f""" <auth username='{ceph_user}'>
<secret type='ceph' uuid='{secret_uuid}'/>
</auth>
<source protocol='rbd' name='{volume["path"]}'>"""
if isinstance(ceph_hosts, list):
for host in ceph_hosts:
if host.get("port"):
xml += f"""
<host name='{host.get("name")}' port='{host.get("port")}'/>"""
else:
xml += f"""<host name='{host.get("name")}'/>"""
xml += """</source>"""
else:
xml += f"""<disk type='file' device='{volume["device"]}'>"""
xml += f""" <driver name='qemu' type='{volume["type"]}' {disk_opts}/>"""
xml += f""" <source file='{volume["path"]}'/>"""
if volume.get("bus") in dom_caps["disk_bus"]:
dev_prefix = util.vol_dev_type(volume.get("bus"))
xml += """<target dev='%s%s' bus='%s'/>""" % (dev_prefix, get_letter(volume.get("bus")), volume.get("bus"))
else:
xml += """<target dev='sd%s'/>""" % get_letter("sata")
xml += """</disk>"""
if volume.get("bus") == "scsi":
xml += f"""<controller type='scsi' model='{volume.get('scsi_model')}'/>"""
if add_cdrom != "None":
xml += """<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<source file = '' />
<readonly/>"""
if add_cdrom in dom_caps["disk_bus"]:
dev_prefix = util.vol_dev_type(add_cdrom)
xml += """<target dev='%s%s' bus='%s'/>""" % (dev_prefix, get_letter(add_cdrom), add_cdrom)
xml += """</disk>"""
# NICs without a given MAC get one from libvirt
macs = mac.split(",") if mac else []
for idx, net in enumerate(networks.split(",")):
xml += """<interface type='network'>"""
if idx < len(macs):
xml += f"""<mac address='{macs[idx].strip()}'/>"""
xml += f"""<source network='{net}'/>"""
if nwfilter:
xml += f"""<filterref filter='{nwfilter}'/>"""
if virtio:
xml += f"""<model type='{net_model}'/>"""
xml += """</interface>"""
if console_pass == "random":
console_pass = "passwd='" + util.randomPasswd() + "'"
elif console_pass != "":
console_pass = "passwd='" + console_pass + "'"
# No bare USB controller: libvirt adds the machine's default
# (qemu-xhci on q35) for USB inputs; a bare one is piix3-uhci.
if add_input != "None":
if add_input in dom_caps["disk_bus"]:
xml += f"""<input type='mouse' bus='{add_input}'/>"""
xml += f"""<input type='keyboard' bus='{add_input}'/>"""
xml += f"""<input type='tablet' bus='{add_input}'/>"""
else:
xml += """<input type='mouse'/>"""
xml += """<input type='keyboard'/>"""
xml += """<input type='tablet'/>"""
xml += f"""
<graphics type='vnc' port='-1' autoport='yes' {console_pass} listen='{listener_addr}'/>
<console type='pty'/> """
if qemu_ga and virtio:
xml += """ <channel type='unix'>
<target type='virtio' name='org.qemu.guest_agent.0'/>
</channel>"""
xml += f""" <video>
<model type='{video}'/>
</video>
</devices>
</domain>"""
return self._defineXML(xml)