1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00

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.
This commit is contained in:
catborise 2026-10-25 19:42:19 +03:00
parent 890d82f44a
commit f004552148
13 changed files with 352 additions and 20 deletions

View file

@ -1,4 +1,5 @@
import contextlib
import io
import os
import re
import socket
@ -919,6 +920,25 @@ class wvmConnect(object):
with contextlib.suppress(libvirtError):
pool.destroy()
def upload_volume(self, pool, name, data):
"""A new raw volume in pool holding data, written through a libvirt
stream, which every connection type carries. A failed write leaves
no volume."""
vol = pool.createXML(
f"<volume><name>{util.xml_escape(name)}</name><capacity>{len(data)}</capacity>"
"<target><format type='raw'/></target></volume>",
0,
)
try:
stream = self.wvm.newStream(0)
vol.upload(stream, 0, len(data), 0)
stream.sendAll(lambda _stream, size, buffer: buffer.read(size), io.BytesIO(data))
stream.finish()
except Exception:
vol.delete(0)
raise
return vol
@staticmethod
def _find_volume(pool, name):
try:

View file

@ -1,10 +1,32 @@
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")
@ -144,6 +166,11 @@ class wvmCreate(wvmConnect):
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,

View file

@ -1,6 +1,5 @@
import base64
import contextlib
import io
import ipaddress
import json
import logging
@ -200,20 +199,7 @@ class wvmInstances(wvmConnect):
vol.download(stream, 0, 0, 0)
stream.recvAll(lambda _stream, data, out: out.append(data), chunks)
stream.finish()
data = b"".join(chunks)
copy = target.createXML(
f"<volume><name>{util.xml_escape(name)}</name><capacity>{len(data)}</capacity>"
"<target><format type='raw'/></target></volume>",
0,
)
try:
stream = self.wvm.newStream(0)
copy.upload(stream, 0, len(data), 0)
stream.sendAll(lambda _stream, size, buffer: buffer.read(size), io.BytesIO(data))
stream.finish()
except Exception:
copy.delete(0)
raise
self.upload_volume(target, name, b"".join(chunks))
return name
def delete_nvram(self, name):
@ -1525,10 +1511,14 @@ class wvmInstance(wvmConnect):
def split_disk_paths_by_use(self):
"""This VM's disk paths, split into (only this VM, also used by another
domain). With external snapshots, the files below a disk that the
snapshots were taken on, and their memory files, are the VM's too."""
snapshots were taken on, and their memory files, are the VM's too, and
so is its cloud-init seed, in a CD-ROM drive or ejected."""
self.refresh_instance_pools()
own = [disk["path"] for disk in self.get_disk_devices() if disk["path"]]
own += self._snapshot_chain_files(own)
seed = self._seed_path()
if seed:
own.append(seed)
used_elsewhere = self.paths_used_by_other_domains(own)
# a kept file keeps the files below it
for path in [path for path in own if path in used_elsewhere]:
@ -1539,6 +1529,15 @@ class wvmInstance(wvmConnect):
[path for path in own if path in used_elsewhere],
)
def _seed_path(self):
"""The path of this VM's cloud-init seed (cidata-<uuid>.iso) in an active pool, or None."""
name = f"cidata-{self.get_uuid()}.iso"
for pool in self.wvm.listAllStoragePools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
vol = self._find_volume(pool, name)
if vol is not None:
return vol.path()
return None
def _snapshot_chain_files(self, tops):
"""The files of this VM's external snapshots that these disk images
grow from or that grow from them: the backing files snapshots were
@ -1974,6 +1973,11 @@ class wvmInstance(wvmConnect):
raise ValueError("An instance with the clone name already exists")
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML))
# the cloud-init seed holds the VM's own instance-id and hostname: the clone's drive stays empty
seed = f"cidata-{tree.findtext('uuid')}.iso"
for source in tree.xpath("devices/disk[@device='cdrom']/source"):
if os.path.basename(source.get("file") or "") == seed:
source.getparent().remove(source)
tree.find("name").text = clone_data["name"]
uuid_el = tree.find("uuid")
if uuid_el is None:

View file

@ -0,0 +1,131 @@
"""A new VM's cloud-init NoCloud seed: an ISO labelled cidata with its
meta-data and user-data, written into a storage pool as a volume."""
import io
import json
import unittest
from unittest.mock import MagicMock
from django.conf import settings
if not settings.configured:
settings.configure(MAC_OUI="52:54:10")
import pycdlib
from libvirt import VIR_ERR_NO_STORAGE_VOL, libvirtError
from vrtManager.connection import wvmConnect
from vrtManager.create import nocloud_seed, wvmCreate
UUID = "11111111-2222-3333-4444-555555555555"
USER_DATA = "#cloud-config\nssh_authorized_keys:\n - ssh-ed25519 AAAA laptop\n"
def read_iso(data):
iso = pycdlib.PyCdlib()
iso.open_fp(io.BytesIO(data))
files = {}
for name in ("meta-data", "user-data"):
out = io.BytesIO()
iso.get_file_from_iso_fp(out, rr_path=f"/{name}")
files[name] = out.getvalue().decode()
out = io.BytesIO()
iso.get_file_from_iso_fp(out, joliet_path=f"/{name}")
assert out.getvalue().decode() == files[name]
label = iso.pvd.volume_identifier.decode().strip()
iso.close()
return label, files
class SeedTestCase(unittest.TestCase):
def test_the_seed_carries_the_vms_identity_and_user_data(self):
label, files = read_iso(nocloud_seed(UUID, "web-1", USER_DATA))
# cloud-init finds the seed by its label, and runs again only for a new instance-id
self.assertEqual(label, "cidata")
self.assertEqual(json.loads(files["meta-data"]), {"instance-id": UUID, "local-hostname": "web-1"})
self.assertEqual(files["user-data"], USER_DATA)
def test_any_vm_name_stays_a_string_hostname(self):
# YAML would read 123 as a number, yes as true and null as nothing
import yaml
for name in ("123", "yes", "null", "1e3", "web.example"):
with self.subTest(name=name):
meta = yaml.safe_load(read_iso(nocloud_seed(UUID, name, USER_DATA))[1]["meta-data"])
self.assertEqual(meta, {"instance-id": UUID, "local-hostname": name})
def test_the_seed_goes_into_the_pool_as_a_volume(self):
conn = wvmCreate.__new__(wvmCreate)
conn.wvm = MagicMock()
pool = conn.wvm.storagePoolLookupByName.return_value
pool.createXML.return_value.path.return_value = f"/var/lib/libvirt/images/cidata-{UUID}.iso"
sent = []
conn.wvm.newStream.return_value.sendAll.side_effect = lambda handler, buffer: sent.append(buffer.read())
path = conn.create_seed("default", UUID, "web-1", USER_DATA)
self.assertEqual(path, f"/var/lib/libvirt/images/cidata-{UUID}.iso")
conn.wvm.storagePoolLookupByName.assert_called_once_with("default")
xml = pool.createXML.call_args.args[0]
self.assertIn(f"<name>cidata-{UUID}.iso</name>", xml)
self.assertEqual(json.loads(read_iso(sent[0])[1]["meta-data"])["instance-id"], UUID)
self.assertIn(f"<capacity>{len(sent[0])}</capacity>", xml)
class UploadVolumeTestCase(unittest.TestCase):
def test_a_failed_write_leaves_no_volume(self):
conn = wvmConnect.__new__(wvmConnect)
conn.wvm = MagicMock()
conn.wvm.newStream.return_value.finish.side_effect = RuntimeError("stream broke")
pool = MagicMock()
with self.assertRaises(RuntimeError):
conn.upload_volume(pool, "a.iso", b"data")
pool.createXML.return_value.delete.assert_called_once_with(0)
class DeleteWithDisksTestCase(unittest.TestCase):
def vm(self, seed_in_pool=True):
from vrtManager.instance import wvmInstance
vm = wvmInstance.__new__(wvmInstance)
vm.wvm = MagicMock()
vm.refresh_instance_pools = MagicMock()
vm.get_uuid = lambda: UUID
vm.get_disk_devices = lambda: [{"path": "/pool/web-1.qcow2"}]
vm._snapshot_chain_files = lambda own: []
vm.paths_used_by_other_domains = lambda own: set()
pool = MagicMock()
if seed_in_pool:
pool.storageVolLookupByName.return_value.path.return_value = f"/pool/cidata-{UUID}.iso"
else:
missing = libvirtError("no volume")
missing.get_error_code = lambda: VIR_ERR_NO_STORAGE_VOL
pool.storageVolLookupByName.side_effect = missing
vm.wvm.listAllStoragePools.return_value = [pool]
return vm, pool
def test_the_vms_own_seed_goes_with_its_disks_even_when_ejected(self):
# found in the pools by its name, not in a CD-ROM drive: one may hold an installer now
vm, pool = self.vm()
self.assertEqual(vm.split_disk_paths_by_use(), (["/pool/web-1.qcow2", f"/pool/cidata-{UUID}.iso"], []))
pool.storageVolLookupByName.assert_called_once_with(f"cidata-{UUID}.iso")
def test_a_vm_without_a_seed_deletes_only_its_disks(self):
vm, _ = self.vm(seed_in_pool=False)
self.assertEqual(vm.split_disk_paths_by_use(), (["/pool/web-1.qcow2"], []))
class CloneTestCase(unittest.TestCase):
def test_a_clone_does_not_take_the_vms_seed(self):
# the seed holds the VM's instance-id and hostname: the clone's CD-ROM is left empty
from vrtManager.instance import wvmInstance
vm = wvmInstance.__new__(wvmInstance)
vm.get_instances = lambda: []
vm.get_uuid = lambda: UUID
vm._XMLDesc = lambda flags: (
"<domain><name>web-1</name><uuid>" + UUID + "</uuid><devices>"
"<disk type='file' device='cdrom'><source file='/pool/cidata-" + UUID + ".iso'/><target dev='sda'/></disk>"
"<disk type='file' device='cdrom'><source file='/iso/alma10.iso'/><target dev='sdb'/></disk>"
"</devices></domain>")
tree, _ = vm._clone_definition({"name": "web-2"}, "99999999-2222-3333-4444-555555555555")
sources = [d.find("source").get("file") if d.find("source") is not None else None
for d in tree.findall("devices/disk")]
self.assertEqual(sources, [None, "/iso/alma10.iso"])

View file

@ -765,6 +765,9 @@ class DestroyFilesTestCase(unittest.TestCase):
p.get_volume_by_path = volume
pool = MagicMock(**{"isActive.return_value": True})
pool.listAllVolumes.return_value = [volume(path) for path in backing]
no_seed = libvirtError("no volume")
no_seed.err = (libvirt.VIR_ERR_NO_STORAGE_VOL,)
pool.storageVolLookupByName.side_effect = no_seed # the VM has no cloud-init seed
p.wvm.listAllStoragePools.return_value = [pool]
p.wvm.listAllDomains.return_value = []
return p.split_disk_paths_by_use()