Быстрое создание машин: cloud-init, тип системы для установщика, постоянный адрес в сети NAT

- готовые образы: пользователь, пароль (хеш SHA-512) и SSH-ключ через virt-install --cloud-init,
  диск увеличивается до выбранного размера, osinfo из каталога
- установка с ISO: выбор Linux / Windows 11 (UEFI + TPM) / Windows 10 / другая;
  Windows получает SATA-диск и сетевую карту e1000e
- каждая машина в NAT получает фиксированный MAC и резервацию DHCP — адрес не меняется,
  пробросы портов не ломаются; при удалении резервация снимается
- xorriso в пакетах ноды (для seed-образа cloud-init)
- tests/test_quickvm.py; тест каталога скачиваний сделан детерминированным

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 15:14:03 +00:00
родитель eb7b71f478
Коммит e5ce9c4d41
10 изменённых файлов: 438 добавлений и 20 удалений
+54
Просмотреть файл
@@ -177,6 +177,60 @@ def nat_network_xml() -> str:
"""
def stable_mac(vm_name: str) -> str:
"""Locally administered QEMU MAC derived from the machine name (same name → same address)."""
import hashlib
digest = hashlib.sha1(vm_name.encode()).digest()
return '52:54:00:' + ':'.join(f'{b:02x}' for b in digest[:3])
def nat_reservations() -> list[dict[str, str]]:
"""DHCP reservations of the NAT network: [{'name', 'mac', 'ip'}]."""
result = run_cmd(['virsh', 'net-dumpxml', NETWORK_NAME], timeout=8)
if not result['ok']:
return []
return [
{'mac': m.group(1).lower(), 'name': m.group(2), 'ip': m.group(3)}
for m in re.finditer(r"<host\s+mac='([^']+)'\s+name='([^']+)'\s+ip='([^']+)'\s*/>", result['stdout'])
]
def _net_update(action: str, host_xml: str) -> dict[str, Any]:
base = ['virsh', 'net-update', NETWORK_NAME, action, 'ip-dhcp-host', host_xml]
result = run_cmd(base + ['--live', '--config'], timeout=15)
if not result['ok'] and 'not active' in (result['stderr'] + result['stdout']).lower():
result = run_cmd(base + ['--config'], timeout=15)
return result
def reserve_nat_address(vm_name: str, mac: str) -> str | None:
"""Pin the machine to one NAT address for its whole life; None when libvirt refused."""
if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name or ''):
return None
reservations = nat_reservations()
for item in reservations:
if item['name'] == vm_name:
if item['mac'] != mac.lower():
_net_update('modify', f"<host mac='{mac}' name='{vm_name}' ip='{item['ip']}'/>")
return item['ip']
taken = {item['ip'] for item in reservations}
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)
taken.update(re.findall(r'\b(192\.168\.100\.\d+)/', leases['stdout'] if leases['ok'] else ''))
start, end = int(DHCP_START.rsplit('.', 1)[1]), int(DHCP_END.rsplit('.', 1)[1])
free = next((f'192.168.100.{last}' for last in range(start, end + 1) if f'192.168.100.{last}' not in taken), None)
if not free:
return None
result = _net_update('add-last', f"<host mac='{mac}' name='{vm_name}' ip='{free}'/>")
return free if result['ok'] else None
def release_nat_address(vm_name: str) -> None:
for item in nat_reservations():
if item['name'] == vm_name:
_net_update('delete', f"<host mac='{item['mac']}' name='{item['name']}' ip='{item['ip']}'/>")
def libvirt_network_info() -> dict[str, Any]:
info = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8)
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)