feat!: declare the guest agent per guest OS, and share what provisioning needs
CI / test (3.10) (push) Successful in 39s
CI / test (3.11) (push) Successful in 36s
CI / test (3.12) (push) Successful in 39s
CI / test (3.10) (pull_request) Successful in 32s
CI / test (3.11) (pull_request) Successful in 23s
CI / test (3.12) (pull_request) Successful in 24s

GUEST_AGENT_PACKAGES / GUEST_AGENT_RUNCMD described one agent per
hypervisor. A hypervisor that provisions FreeBSD and OpenBSD guests needs
a different agent per guest, and netOrk has to know which guests a driver
can provision at all (NetOrk/netork#794). Both now come from one
class-level mapping netOrk reads off the driver class:

    GUEST_AGENTS = {guest_os: (packages, runcmd)}

Its keys are the guests the driver provisions, and
create_vm_from_cloud_init(guest_os=...) refuses any other. The base keeps
Linux with qemu-guest-agent.

New module `provisioning`, generic for every hypervisor:
- QEMU_GUEST_AGENTS: the QEMU agent for Linux, FreeBSD and OpenBSD, with
  package names and service commands verified on FreeBSD 15.1 and
  OpenBSD 7.9 cloud images (NetOrk/netork#793).
- network_config(): cloud-init's network-config v2 with MAC matching,
  moved here from napalm-vmware. FreeBSD's nuageinit reads no other
  version.
- split_compression(): how a packed image (.xz, .gz, .bz2, .zst) is
  unpacked.

BREAKING CHANGE: GUEST_AGENT_PACKAGES and GUEST_AGENT_RUNCMD are gone;
drivers declare GUEST_AGENTS instead.
This commit is contained in:
2026-10-08 07:53:21 +02:00
parent eb80d5cb0d
commit 885c7e1f53
6 changed files with 191 additions and 23 deletions
+14 -5
View File
@@ -14,6 +14,7 @@ from typing import Any, Dict, List, TYPE_CHECKING
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.provisioning import QEMU_GUEST_AGENTS, GuestAgents
from napalm_device_types.models import (
NICConfigDict,
NetworkTargetDict,
@@ -44,11 +45,11 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
ROLE: str = "hypervisor"
TYPE_LABEL: str = "Hypervisor"
#: What cloud-init installs and starts on a VM provisioned through this
#: driver, so the hypervisor can read the guest's IP address back.
GUEST_AGENT_PACKAGES: tuple[str, ...] = ("qemu-guest-agent",)
GUEST_AGENT_RUNCMD: tuple[str, ...] = ("systemctl enable --now qemu-guest-agent",)
#: What cloud-init installs and runs on a VM provisioned through this
#: driver, per guest OS, so the hypervisor can read the guest's IP
#: address back. The guest operating systems listed here are the ones
#: the driver can provision; netOrk reads this off the class.
GUEST_AGENTS: GuestAgents = {"linux": QEMU_GUEST_AGENTS["linux"]}
# ------------------------------------------------------------------
@@ -464,6 +465,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
disk_resize_gb: int | None = None,
storage: str | None = None,
cpu_type: str | None = None,
guest_os: str = "linux",
download_timeout: int = 300,
timeout: int = 180,
) -> VMProvisionResultDict:
@@ -512,6 +514,13 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
value other than None with ValueError; one that has it raises
ValueError for a name it does not list or that is not
``available`` on this node, before creating anything.
guest_os (string) - the operating system in the image, a key of
``GUEST_AGENTS`` (``"linux"``, ``"freebsd"``, ``"openbsd"``). The
driver sets up the VM's hardware for it (OS type, how the guest
agent is attached) and, where the guest needs it, writes the
network-config itself (``provisioning.network_config``). A
guest_os the driver does not list raises ValueError before
anything is created. Default ``"linux"``.
download_timeout (int) - maximum seconds to wait for the image download
(skipped entirely if already cached on the hypervisor). Default 300.
timeout (int) - maximum seconds to wait for the remaining provisioning
+64
View File
@@ -0,0 +1,64 @@
"""
What provisioning a VM from a cloud image needs to know about the guest.
The same for every hypervisor: which agent a guest runs so a QEMU-based
hypervisor can read its IP, the network-config cloud-init and FreeBSD's
nuageinit both read, and how a packed cloud image is unpacked. How a
hypervisor attaches, boots and talks to the VM stays in its driver.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
#: ``{guest_os: (packages, runcmd)}``: what cloud-init installs and runs so
#: the hypervisor can read the guest's IP address back.
GuestAgents = Mapping[str, tuple[tuple[str, ...], tuple[str, ...]]]
#: The QEMU guest agent per guest OS, for hypervisors built on QEMU. Package
#: names and service commands were verified on FreeBSD 15.1 and OpenBSD 7.9
#: cloud images (NetOrk/netork#793).
QEMU_GUEST_AGENTS: GuestAgents = {
"linux": (("qemu-guest-agent",), ("systemctl enable --now qemu-guest-agent",)),
"freebsd": (
("qemu-guest-agent",),
("sysrc qemu_guest_agent_enable=YES", "service qemu-guest-agent start"),
),
"openbsd": (("qemu-ga",), ("rcctl enable qemu_ga", "rcctl start qemu_ga")),
}
#: Suffix of a packed image -> the command that writes it unpacked to stdout.
_DECOMPRESSORS = {
"xz": "xz -dc",
"gz": "gzip -dc",
"bz2": "bzip2 -dc",
"zst": "zstd -dc",
}
def network_config(nics: list[tuple[str, bool]]) -> dict[str, Any] | None:
"""Network-config v2: DHCP on every ``(mac, dhcp)`` NIC that asks for it.
Version 2 because FreeBSD's nuageinit reads no other: given the v1 that
Proxmox generates, it fails and skips the rest of its first stage,
runcmd included. ``None`` when no NIC wants DHCP, so the guest is left
alone rather than told to configure nothing.
"""
ethernets = {
f"nic{i}": {"match": {"macaddress": mac.lower()}, "dhcp4": True}
for i, (mac, dhcp) in enumerate(nics)
if dhcp
}
return {"version": 2, "ethernets": ethernets} if ethernets else None
def split_compression(filename: str) -> tuple[str, str | None]:
"""``(unpacked filename, command that unpacks to stdout)`` for *filename*.
The command is None for an image that is not packed, which is then
imported as it is.
"""
stem, dot, suffix = filename.rpartition(".")
command = _DECOMPRESSORS.get(suffix.lower()) if dot and stem else None
return (stem, command) if command else (filename, None)