feat(vm-provision): provision FreeBSD and OpenBSD guests
CI / test (3.10) (push) Successful in 34s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 36s
CI / test (3.10) (pull_request) Successful in 34s
CI / test (3.11) (pull_request) Successful in 32s
CI / test (3.12) (pull_request) Successful in 35s
CI / test (3.10) (push) Successful in 34s
CI / test (3.11) (push) Successful in 32s
CI / test (3.12) (push) Successful in 36s
CI / test (3.10) (pull_request) Successful in 34s
CI / test (3.11) (pull_request) Successful in 32s
CI / test (3.12) (pull_request) Successful in 35s
create_vm_from_cloud_init(guest_os=...) with GUEST_AGENTS from napalm-device-types 3.0 (QEMU_GUEST_AGENTS: Linux, FreeBSD, OpenBSD). An unknown guest_os is refused before anything is created. Found on FreeBSD 15.1 and OpenBSD 7.9 cloud images (NetOrk/netork#793): - OS type `other` for the BSDs. OpenBSD's qemu-ga only works over ISA serial, and only as the second port: the image keeps its console on com0, so the VM gets `serial0: socket` next to `agent: 1,type=isa`. - A BSD guest gets a network-config v2 snippet of its own (`cicustom: user=...,network=...`, MAC read back from net0). FreeBSD's nuageinit fails on the v1 Proxmox generates and then skips runcmd, which starts the guest agent. Linux keeps Proxmox's own. - Packed images (FreeBSD's .qcow2.xz) are unpacked on the node after the packed file's checksum is verified; only the unpacked file is cached. download-url unpacks ISOs only, so they always take the SSH path. Fixed on the way: - get_vm_status skipped loopback only when it was called `lo`; OpenBSD's agent lists `lo0` first, so 127.0.0.1 would have been the VM's IP. Loopback is now recognised by its address. - destroy_vm read cicustom after deleting the VM, when its config was gone, so snippets stayed on the node; it now reads them first and removes all of them (#15).
This commit is contained in:
@@ -4,19 +4,26 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import yaml
|
||||
from typing import TYPE_CHECKING, Any, Dict, List
|
||||
from urllib.parse import quote
|
||||
|
||||
import yaml
|
||||
from napalm_device_types.models import (
|
||||
NetworkTargetDict,
|
||||
StorageTargetDict,
|
||||
VMProvisionResultDict,
|
||||
VMStatusDict,
|
||||
)
|
||||
from napalm_device_types.provisioning import (
|
||||
QEMU_GUEST_AGENTS,
|
||||
GuestAgents,
|
||||
network_config,
|
||||
split_compression,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Type-only: VMCpuTypeDict is newer than the napalm_device_types floor in
|
||||
@@ -76,6 +83,18 @@ _IMAGE_CACHE_DIR = "/var/lib/vz/template/netork-images"
|
||||
# raw for a qcow2 would attach the qcow2 container as a raw disk silently.
|
||||
_IMPORT_EXTENSIONS = {"qcow2": "qcow2", "raw": "raw", "vmdk": "vmdk", "img": "qcow2"}
|
||||
|
||||
# The VM shell each guest needs. OpenBSD's qemu-ga cannot use virtio-serial,
|
||||
# and over ISA serial it answers only as the second port (cua01): the OpenBSD
|
||||
# cloud image keeps its console on com0, which serial0 takes (netork#793).
|
||||
_GUEST_VM_SETTINGS: dict[str, dict[str, str]] = {
|
||||
"linux": {"ostype": "l26", "agent": "1"},
|
||||
"freebsd": {"ostype": "other", "agent": "1"},
|
||||
"openbsd": {"ostype": "other", "agent": "1,type=isa", "serial0": "socket"},
|
||||
}
|
||||
|
||||
# A NIC as Proxmox reports it: "virtio=BC:24:11:AA:BB:02,bridge=vmbr0".
|
||||
_NIC_MAC = re.compile(r"^[a-z0-9]+=([0-9A-Fa-f:]{17})")
|
||||
|
||||
# Characters Proxmox keeps in a content file name (PVE::Storage's
|
||||
# SAFE_CHAR_CLASS_RE); anything else it would rewrite behind our back.
|
||||
_UNSAFE_FILENAME_CHARS = re.compile(r"[^A-Za-z0-9\-.+=_]")
|
||||
@@ -111,6 +130,10 @@ def _import_volume_name(image_url: str) -> str | None:
|
||||
class ProxmoxVMProvisionMixin:
|
||||
"""Mixin to add VM provisioning to ProxmoxDriver."""
|
||||
|
||||
#: Every guest QEMU's agent runs on; see _GUEST_VM_SETTINGS for the VM
|
||||
#: hardware each one needs.
|
||||
GUEST_AGENTS: GuestAgents = QEMU_GUEST_AGENTS
|
||||
|
||||
def _run_node_command(self, command: str, timeout: int) -> str:
|
||||
"""
|
||||
Execute a shell command on the Proxmox node via SSH, raising on failure.
|
||||
@@ -214,6 +237,61 @@ class ProxmoxVMProvisionMixin:
|
||||
|
||||
raise AssertionError("unreachable") # loop always returns or raises above
|
||||
|
||||
def _cloud_image_on_node(self, image_url: str, image_checksum: str | None, timeout: int) -> str:
|
||||
"""The node path of the image, downloaded, verified and unpacked.
|
||||
|
||||
A packed image (FreeBSD ships .qcow2.xz) is unpacked on the node after
|
||||
its checksum, which covers the packed file, has been verified; only
|
||||
the unpacked file is kept. Proxmox's download-url unpacks ISOs only,
|
||||
so a packed image always comes this way.
|
||||
"""
|
||||
filename = image_url.rstrip("/").rsplit("/", 1)[-1]
|
||||
unpacked_name, unpack = split_compression(filename)
|
||||
if unpack is None:
|
||||
return self._download_cloud_image(image_url, image_checksum, timeout=timeout)
|
||||
|
||||
unpacked = f"{_IMAGE_CACHE_DIR}/{_url_key(image_url)}-{unpacked_name}"
|
||||
cached = self._run_node_command(
|
||||
f"mkdir -p {_IMAGE_CACHE_DIR} && test -f {unpacked} && echo EXISTS || echo MISSING",
|
||||
timeout=30,
|
||||
)
|
||||
if "EXISTS" in cached:
|
||||
return unpacked
|
||||
packed = self._download_cloud_image(image_url, image_checksum, timeout=timeout)
|
||||
_logger.info(f"Unpacking {packed} -> {unpacked}")
|
||||
self._run_node_command(
|
||||
f"{unpack} {packed} > {unpacked}.tmp && mv {unpacked}.tmp {unpacked} && rm -f {packed}",
|
||||
timeout=timeout,
|
||||
)
|
||||
return unpacked
|
||||
|
||||
def _write_snippet(self, storage_path: str, filename: str, content: str) -> None:
|
||||
"""Write a cloud-init snippet into *storage_path*/snippets on the node.
|
||||
|
||||
Proxmox's /storage/{s}/upload API only accepts content in
|
||||
{iso, vztmpl, import} — "snippets" is rejected outright ("does not
|
||||
have a value in the enumeration"). Snippets can only be written
|
||||
directly to the filesystem, so this goes over SSH.
|
||||
"""
|
||||
_logger.debug(f"Writing Cloud-Init snippet {filename} to {storage_path}/snippets")
|
||||
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
self._run_node_command(
|
||||
f"mkdir -p {storage_path}/snippets && "
|
||||
f"echo {encoded} | base64 -d > {storage_path}/snippets/{filename}",
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def _nic_macs(self, vmid: int, nics: List[Dict[str, Any]]) -> list[tuple[str, bool]]:
|
||||
"""``(mac, dhcp)`` per NIC, with the MAC Proxmox assigned where none was given."""
|
||||
config = self._node_api().qemu(vmid).config.get()
|
||||
macs = []
|
||||
for i, nic in enumerate(nics):
|
||||
match = _NIC_MAC.match(config.get(f"net{i}", ""))
|
||||
if not match:
|
||||
raise RuntimeError(f"VM {vmid} has no MAC address on net{i}")
|
||||
macs.append((match.group(1), nic.get("dhcp", i == 0)))
|
||||
return macs
|
||||
|
||||
def _find_import_storage(self) -> str | None:
|
||||
"""The first storage on this node that accepts content "import".
|
||||
|
||||
@@ -276,7 +354,7 @@ class ProxmoxVMProvisionMixin:
|
||||
The path for nodes without an import storage, or for an image type
|
||||
Proxmox cannot import itself.
|
||||
"""
|
||||
local_path = self._download_cloud_image(image_url, image_checksum, timeout=download_timeout)
|
||||
local_path = self._cloud_image_on_node(image_url, image_checksum, download_timeout)
|
||||
_logger.info(f"Importing {local_path} into VM {vmid} on storage {image_storage}")
|
||||
self._run_node_command(
|
||||
f"qm importdisk {vmid} {local_path} {image_storage} --format qcow2",
|
||||
@@ -491,6 +569,7 @@ class ProxmoxVMProvisionMixin:
|
||||
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:
|
||||
@@ -524,6 +603,10 @@ class ProxmoxVMProvisionMixin:
|
||||
storage: storage pool for the root disk (None = auto-detect first
|
||||
enabled, node-available storage with content='images')
|
||||
cpu_type: CPU model from get_vm_cpu_types() (None = x86-64-v2-AES)
|
||||
guest_os: the OS in the image, a key of GUEST_AGENTS. It sets the
|
||||
OS type and how the agent is attached; a guest other than Linux
|
||||
also gets a network-config v2 snippet of its own, because
|
||||
FreeBSD's nuageinit skips runcmd on the v1 Proxmox writes.
|
||||
download_timeout: max seconds for the image download (skipped if cached)
|
||||
timeout: max seconds for the remaining provisioning steps
|
||||
|
||||
@@ -536,8 +619,13 @@ class ProxmoxVMProvisionMixin:
|
||||
unknown or that this node's CPU cannot run (raised before
|
||||
anything is created)
|
||||
"""
|
||||
if guest_os not in self.GUEST_AGENTS:
|
||||
raise ValueError(
|
||||
f"Cannot provision a {guest_os!r} guest; this driver provisions "
|
||||
f"{', '.join(sorted(self.GUEST_AGENTS))}"
|
||||
)
|
||||
try:
|
||||
_logger.info(f"Creating VM '{name}' from image {image_url}")
|
||||
_logger.info(f"Creating {guest_os} VM '{name}' from image {image_url}")
|
||||
cpu_model = self._resolve_cpu_type(cpu_type)
|
||||
|
||||
# Step 1: Get next VMID
|
||||
@@ -553,12 +641,11 @@ class ProxmoxVMProvisionMixin:
|
||||
memory=memory,
|
||||
cores=cpu,
|
||||
cpu=cpu_model,
|
||||
ostype="l26",
|
||||
scsihw="virtio-scsi-pci",
|
||||
# Without this, Proxmox never attaches the virtio-serial
|
||||
# channel the QEMU guest agent needs — get_vm_status's
|
||||
# agent queries (below) would have nothing to talk to.
|
||||
agent="1",
|
||||
# Includes agent: without it Proxmox never attaches the
|
||||
# channel the QEMU guest agent needs — get_vm_status's agent
|
||||
# queries (below) would have nothing to talk to.
|
||||
**_GUEST_VM_SETTINGS[guest_os],
|
||||
)
|
||||
|
||||
# Step 3: Download cloud image (cached) and import as root disk
|
||||
@@ -621,20 +708,20 @@ class ProxmoxVMProvisionMixin:
|
||||
)
|
||||
|
||||
filename = f"{vmid}-user-data.yaml"
|
||||
_logger.debug(f"Writing Cloud-Init snippet {filename} to {snippet_storage}")
|
||||
|
||||
# Proxmox's /storage/{s}/upload API only accepts content in
|
||||
# {iso, vztmpl, import} — "snippets" is rejected outright
|
||||
# ("does not have a value in the enumeration"). Snippets can only
|
||||
# be written directly to the filesystem, so resolve the storage's
|
||||
# backing path and write the file over SSH instead.
|
||||
storage_path = self._get_storage_path(snippet_storage)
|
||||
encoded = base64.b64encode(user_data_yaml.encode("utf-8")).decode("ascii")
|
||||
self._run_node_command(
|
||||
f"mkdir -p {storage_path}/snippets && "
|
||||
f"echo {encoded} | base64 -d > {storage_path}/snippets/{filename}",
|
||||
timeout=30,
|
||||
)
|
||||
self._write_snippet(storage_path, filename, user_data_yaml)
|
||||
cicustom = f"user={snippet_storage}:snippets/{filename}"
|
||||
|
||||
# Proxmox writes network-config v1, and FreeBSD's nuageinit fails
|
||||
# on it and then skips runcmd, which starts the guest agent. A
|
||||
# guest other than Linux gets the v2 every cloud-init reads.
|
||||
network = network_config(self._nic_macs(vmid, nics)) if guest_os != "linux" else None
|
||||
if network:
|
||||
network_filename = f"{vmid}-network-config.yaml"
|
||||
self._write_snippet(
|
||||
storage_path, network_filename, yaml.safe_dump(network, sort_keys=False)
|
||||
)
|
||||
cicustom += f",network={snippet_storage}:snippets/{network_filename}"
|
||||
|
||||
# Step 7: Configure Cloud-Init references and SSH keys
|
||||
_logger.info(f"Setting Cloud-Init config for VM {vmid}")
|
||||
@@ -648,7 +735,7 @@ class ProxmoxVMProvisionMixin:
|
||||
# this points at a snippets-only storage.
|
||||
"ide2": f"{image_storage}:cloudinit",
|
||||
"citype": "nocloud",
|
||||
"cicustom": f"user={snippet_storage}:snippets/{filename}",
|
||||
"cicustom": cicustom,
|
||||
}
|
||||
|
||||
# Configure DHCP for NICs where enabled (default True for index 0, False otherwise)
|
||||
@@ -736,6 +823,10 @@ class ProxmoxVMProvisionMixin:
|
||||
except Exception as e:
|
||||
_logger.debug(f"VM {vmid} stop failed (may already be stopped): {e}")
|
||||
|
||||
# The snippets have to be known before the delete: afterwards the
|
||||
# VM's config is gone (napalm-proxmox#15).
|
||||
snippets = self._cicustom_snippets(vmid_int)
|
||||
|
||||
# Step 2: Delete VM
|
||||
# Proxmox's API parameter is hyphenated (destroy-unreferenced-disks),
|
||||
# not a valid Python identifier — proxmoxer forwards kwargs to the
|
||||
@@ -750,21 +841,12 @@ class ProxmoxVMProvisionMixin:
|
||||
|
||||
# Step 3: Clean up Cloud-Init snippets
|
||||
# (This is best-effort; snippet files may be unreachable if storage is unavailable)
|
||||
try:
|
||||
config = self._node_api().qemu(vmid_int).config.get()
|
||||
cicustom = config.get("cicustom", "")
|
||||
if "snippets/" in cicustom:
|
||||
parts = cicustom.split("=")
|
||||
if len(parts) >= 2:
|
||||
snippet_ref = parts[1] # e.g. "snippets:snippets/101-user-data.yaml"
|
||||
storage, filepath = snippet_ref.split(":", 1)
|
||||
_logger.debug(f"Deleting snippet {filepath} from {storage}")
|
||||
try:
|
||||
self._node_api().storage(storage).content(filepath).delete()
|
||||
except Exception as e:
|
||||
_logger.warning(f"Failed to delete snippet {filepath}: {e}")
|
||||
except Exception as e:
|
||||
_logger.debug(f"Could not clean up snippets for VM {vmid}: {e}")
|
||||
for storage, filepath in snippets:
|
||||
_logger.debug(f"Deleting snippet {filepath} from {storage}")
|
||||
try:
|
||||
self._node_api().storage(storage).content(filepath).delete()
|
||||
except Exception as e:
|
||||
_logger.warning(f"Failed to delete snippet {filepath}: {e}")
|
||||
|
||||
_logger.info(f"VM {vmid} destroyed successfully")
|
||||
|
||||
@@ -772,6 +854,24 @@ class ProxmoxVMProvisionMixin:
|
||||
_logger.exception(f"Failed to destroy VM {vmid}: {e}")
|
||||
raise
|
||||
|
||||
def _cicustom_snippets(self, vmid: int) -> list[tuple[str, str]]:
|
||||
"""``(storage, path)`` of every snippet the VM's cicustom names.
|
||||
|
||||
cicustom reads "user=local:snippets/101-user-data.yaml,network=...".
|
||||
Best-effort: a VM whose config cannot be read has none to clean up.
|
||||
"""
|
||||
try:
|
||||
cicustom = self._node_api().qemu(vmid).config.get().get("cicustom", "")
|
||||
except Exception as e:
|
||||
_logger.debug(f"Could not read the snippets of VM {vmid}: {e}")
|
||||
return []
|
||||
snippets = []
|
||||
for entry in cicustom.split(","):
|
||||
storage, _, path = entry.partition("=")[2].partition(":")
|
||||
if path.startswith("snippets/"):
|
||||
snippets.append((storage, path))
|
||||
return snippets
|
||||
|
||||
def get_vm_status(
|
||||
self,
|
||||
vmid: str,
|
||||
@@ -829,17 +929,18 @@ class ProxmoxVMProvisionMixin:
|
||||
interfaces = (agent_info or {}).get("result", [])
|
||||
|
||||
# The guest agent does not report interfaces in a fixed order —
|
||||
# "lo" commonly comes first. Skip it and take the first real
|
||||
# NIC that has an IPv4 address.
|
||||
# loopback commonly comes first, named "lo" on Linux and
|
||||
# "lo0" on the BSDs. Skip loopback addresses, whatever the
|
||||
# interface is called, and take the first IPv4 address.
|
||||
for iface in interfaces:
|
||||
name = iface.get("name", "")
|
||||
if not name or name == "lo":
|
||||
if not name:
|
||||
continue
|
||||
for addr in iface.get("ip-addresses", []):
|
||||
if addr.get("ip-address-type") != "ipv4":
|
||||
continue
|
||||
ip_addr = addr.get("ip-address", "")
|
||||
if ip_addr:
|
||||
if ip_addr and not ipaddress.ip_address(ip_addr).is_loopback:
|
||||
_logger.info(f"VM {vmid} acquired IP {ip_addr}")
|
||||
return {
|
||||
"status": "running",
|
||||
|
||||
Reference in New Issue
Block a user