Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf1736619 | ||
|
|
ea74e84387 | ||
|
|
52074620ef | ||
|
|
ccb9585f4e | ||
|
|
77e65ea7bd | ||
|
|
6464a6c728 | ||
|
|
a13af149d9 |
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Cloud images for `create_vm_from_cloud_init` are downloaded by Proxmox itself
|
||||
when the node has an active storage with content type `import` (Proxmox
|
||||
8.2+): `download-url` with checksum verification, then `import-from` as the
|
||||
root disk. The image no longer passes through the node's root filesystem.
|
||||
Files are named `netork-<url hash>-<name>.<qcow2|raw|vmdk>` and reused;
|
||||
Ubuntu's `.img` is stored as `.qcow2`. Without such a storage, or for an
|
||||
image type Proxmox cannot import, the SSH download into
|
||||
`/var/lib/vz/template/netork-images` is used as before.
|
||||
- `HypervisorDriver` contract methods `start_vm`, `stop_vm`, `reboot_vm`,
|
||||
`suspend_vm` and `get_vm_config`. They accept a VM's name or vmid, raise
|
||||
`ValueError`/`RuntimeError` instead of returning a result dict, and wait
|
||||
|
||||
@@ -34,7 +34,13 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from napalm_device_types import FingerprintRule, HypervisorDriver, PortSpec
|
||||
from napalm_device_types import (
|
||||
FingerprintRule,
|
||||
HypervisorDriver,
|
||||
KernelFactsMixin,
|
||||
PortSpec,
|
||||
SystemdServicesMixin,
|
||||
)
|
||||
from napalm.base.exceptions import ConnectionException
|
||||
|
||||
try:
|
||||
@@ -76,6 +82,8 @@ class ProxmoxDriver(
|
||||
ProxmoxVMProvisionMixin,
|
||||
ProxmoxRoutingMixin,
|
||||
ProxmoxSystemMixin,
|
||||
KernelFactsMixin,
|
||||
SystemdServicesMixin,
|
||||
HypervisorDriver,
|
||||
):
|
||||
"""NAPALM driver for Proxmox VE nodes."""
|
||||
|
||||
@@ -221,6 +221,14 @@ class ProxmoxSystemMixin:
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Kernel facts (KernelFactsMixin supplies get_kernel_facts)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _run_kernel_facts_command(self, command: str) -> str:
|
||||
"""The transport for ``KernelFactsMixin.get_kernel_facts``: the exec path."""
|
||||
return self._exec_ssh_command(command)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Packages (Debian APT)
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -338,79 +346,16 @@ class ProxmoxSystemMixin:
|
||||
return warnings
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Services (systemd)
|
||||
# Services (systemd, through napalm-device-types' SystemdServicesMixin)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_services(self) -> list[_JsonDict]:
|
||||
"""Return systemd services with running and enabled state.
|
||||
def _run_service_command(self, command: str, *, privileged: bool, timeout: int) -> str:
|
||||
"""The transport for ``SystemdServicesMixin``: the exec path, as root.
|
||||
|
||||
Uses two ``systemctl`` invocations combined in a single SSH command:
|
||||
- ``list-unit-files`` for the static enabled/disabled state
|
||||
- ``list-units`` for the live running state
|
||||
*privileged* needs nothing more on a node the driver reaches as root, and
|
||||
the exec path keeps its own timeout.
|
||||
"""
|
||||
raw = self._exec_ssh_command(
|
||||
"{ systemctl list-unit-files --type=service --no-pager --no-legend --full 2>/dev/null;"
|
||||
" echo '---UNITS---';"
|
||||
" systemctl list-units --type=service --all --no-pager --no-legend --full 2>/dev/null;"
|
||||
" } || true"
|
||||
)
|
||||
|
||||
# Parse enabled state from list-unit-files
|
||||
enabled_map: dict[str, bool] = {}
|
||||
section = "files"
|
||||
for line in raw.splitlines():
|
||||
if line.strip() == "---UNITS---":
|
||||
section = "units"
|
||||
continue
|
||||
parts = line.strip().split(None, 1)
|
||||
if len(parts) < 1:
|
||||
continue
|
||||
unit = parts[0].lstrip("\u25cf").strip()
|
||||
if not unit.endswith(".service"):
|
||||
continue
|
||||
name = unit[: -len(".service")]
|
||||
if section == "files":
|
||||
state = parts[1].strip() if len(parts) > 1 else ""
|
||||
enabled_map[name] = state in ("enabled", "enabled-runtime", "static")
|
||||
|
||||
# Parse running state from list-units
|
||||
running_map: dict[str, bool] = {}
|
||||
section = "files"
|
||||
for line in raw.splitlines():
|
||||
if line.strip() == "---UNITS---":
|
||||
section = "units"
|
||||
continue
|
||||
if section != "units":
|
||||
continue
|
||||
parts = line.strip().lstrip("\u25cf").strip().split(None, 4)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
unit = parts[0]
|
||||
if not unit.endswith(".service"):
|
||||
continue
|
||||
name = unit[: -len(".service")]
|
||||
sub_state = parts[3]
|
||||
running_map[name] = sub_state == "running"
|
||||
|
||||
all_names = sorted(set(enabled_map) | set(running_map))
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"running": running_map.get(name, False),
|
||||
"enabled": enabled_map.get(name, False),
|
||||
"pid": 0,
|
||||
}
|
||||
for name in all_names
|
||||
]
|
||||
|
||||
def manage_service(self, name: str, action: str) -> _JsonDict:
|
||||
"""Start / stop / restart / enable / disable a systemd service."""
|
||||
if not re.match(r'^[a-zA-Z0-9_\-\.@]+$', name):
|
||||
raise ValueError(f"Invalid service name: {name!r}")
|
||||
if action not in ('start', 'stop', 'restart', 'enable', 'disable'):
|
||||
raise ValueError(f"Invalid action: {action!r}")
|
||||
output = self._exec_ssh_command(f"systemctl {action} {name}.service 2>&1 || true")
|
||||
return {"success": True, "output": output}
|
||||
return str(self._exec_ssh_command(command))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Available updates
|
||||
|
||||
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import yaml
|
||||
from typing import Any, Dict, List
|
||||
from typing import TYPE_CHECKING, Any, Dict, List
|
||||
from urllib.parse import quote
|
||||
|
||||
from napalm_device_types.models import (
|
||||
@@ -17,13 +18,95 @@ from napalm_device_types.models import (
|
||||
VMStatusDict,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Type-only: VMCpuTypeDict is newer than the napalm_device_types floor in
|
||||
# pyproject.toml, and nothing here needs it at runtime.
|
||||
from napalm_device_types.models import VMCpuTypeDict
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# The CPU models a new VM may be given. Each lists the /proc/cpuinfo flags it
|
||||
# adds on top of QEMU's qemu64 baseline: what the node's CPU must have for the
|
||||
# model to start at all, and what a guest can count on. The sets follow
|
||||
# Proxmox's own x86-64-v* definitions (qemu-server, PVE/QemuServer/CPUConfig.pm).
|
||||
#
|
||||
# Leaving the model out of qemu.post is not neutral: Proxmox then falls back to
|
||||
# kvm64, which lacks even AES-NI, let alone the AVX MongoDB 5.0+ needs
|
||||
# (netOrk#494). The default below is what the Proxmox GUI picks since PVE 8.
|
||||
_X86_64_V2_AES_FLAGS = ("aes", "popcnt", "pni", "sse4_1", "sse4_2", "ssse3")
|
||||
_X86_64_V3_FLAGS = _X86_64_V2_AES_FLAGS + (
|
||||
"avx",
|
||||
"avx2",
|
||||
"bmi1",
|
||||
"bmi2",
|
||||
"f16c",
|
||||
"fma",
|
||||
"abm",
|
||||
"movbe",
|
||||
"xsave",
|
||||
)
|
||||
_DEFAULT_CPU_TYPE = "x86-64-v2-AES"
|
||||
_CPU_MODELS = (
|
||||
(
|
||||
"x86-64-v2-AES",
|
||||
_X86_64_V2_AES_FLAGS,
|
||||
"Proxmox's own default: runs on practically any x86-64 server CPU and "
|
||||
"can live-migrate between different ones. No AVX.",
|
||||
),
|
||||
(
|
||||
"x86-64-v3",
|
||||
_X86_64_V3_FLAGS,
|
||||
"Adds AVX and AVX2 (which MongoDB 5.0 and later need). Every node the VM "
|
||||
"may run on needs an Intel Haswell or AMD Excavator CPU (2013) or newer.",
|
||||
),
|
||||
)
|
||||
_HOST_CPU_DESCRIPTION = (
|
||||
"This node's CPU, passed through unchanged: fastest, with every feature it "
|
||||
"has, but the VM can only live-migrate to nodes with the same CPU."
|
||||
)
|
||||
|
||||
# Downloaded cloud images are cached here on the hypervisor node, keyed by
|
||||
# filename, so provisioning multiple VMs from the same image only pays the
|
||||
# download cost once.
|
||||
_IMAGE_CACHE_DIR = "/var/lib/vz/template/netork-images"
|
||||
|
||||
# Proxmox reads an import volume's format off its extension and accepts only
|
||||
# these. Ubuntu ships its qcow2 cloud images as ".img", so that is stored as
|
||||
# qcow2: if an .img is really raw, the import fails loudly, whereas guessing
|
||||
# raw for a qcow2 would attach the qcow2 container as a raw disk silently.
|
||||
_IMPORT_EXTENSIONS = {"qcow2": "qcow2", "raw": "raw", "vmdk": "vmdk", "img": "qcow2"}
|
||||
|
||||
# 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\-.+=_]")
|
||||
|
||||
|
||||
def _url_key(image_url: str) -> str:
|
||||
"""Short hash of the full URL — Ubuntu and others publish a new build
|
||||
under the same basename every day, so the basename alone is no cache key."""
|
||||
return hashlib.sha256(image_url.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _split_checksum(image_checksum: str) -> tuple[str, str]:
|
||||
"""``"<algo>:<hex>"`` as ``(algo, hex)``; the algorithm defaults to sha256."""
|
||||
algo, _, expected = image_checksum.partition(":")
|
||||
return (algo or "sha256").lower(), expected
|
||||
|
||||
|
||||
def _import_volume_name(image_url: str) -> str | None:
|
||||
"""The file name *image_url* gets in an import storage.
|
||||
|
||||
None when Proxmox cannot import the image's type at all (compressed,
|
||||
ISO, no extension) — provisioning then downloads it over SSH as before.
|
||||
"""
|
||||
basename = image_url.rstrip("/").rsplit("/", 1)[-1]
|
||||
stem, dot, ext = basename.rpartition(".")
|
||||
extension = _IMPORT_EXTENSIONS.get(ext.lower()) if dot and stem else None
|
||||
if extension is None:
|
||||
return None
|
||||
safe_stem = _UNSAFE_FILENAME_CHARS.sub("_", stem)
|
||||
return f"netork-{_url_key(image_url)}-{safe_stem}.{extension}"
|
||||
|
||||
|
||||
class ProxmoxVMProvisionMixin:
|
||||
"""Mixin to add VM provisioning to ProxmoxDriver."""
|
||||
@@ -89,11 +172,9 @@ class ProxmoxVMProvisionMixin:
|
||||
before raising.
|
||||
"""
|
||||
filename = image_url.rstrip("/").rsplit("/", 1)[-1]
|
||||
url_hash = hashlib.sha256(image_url.encode()).hexdigest()[:12]
|
||||
local_path = f"{_IMAGE_CACHE_DIR}/{url_hash}-{filename}"
|
||||
local_path = f"{_IMAGE_CACHE_DIR}/{_url_key(image_url)}-{filename}"
|
||||
|
||||
algo, _, expected = (image_checksum or "").partition(":")
|
||||
algo = (algo or "sha256").lower()
|
||||
algo, expected = _split_checksum(image_checksum or "")
|
||||
|
||||
max_attempts = 2
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
@@ -133,6 +214,125 @@ class ProxmoxVMProvisionMixin:
|
||||
|
||||
raise AssertionError("unreachable") # loop always returns or raises above
|
||||
|
||||
def _find_import_storage(self) -> str | None:
|
||||
"""The first storage on this node that accepts content "import".
|
||||
|
||||
Such a storage (Proxmox 8.2+) can take a cloud image straight from its
|
||||
URL. Inactive storages (typically a share that is not mounted) are
|
||||
skipped rather than failed on: the SSH path still works without them.
|
||||
Node-scoped for the same reason as _find_default_image_storage.
|
||||
"""
|
||||
for storage in self._node_api().storage.get():
|
||||
content = storage.get("content", "").split(",")
|
||||
if "import" not in content:
|
||||
continue
|
||||
if storage.get("enabled", 1) == 0 or storage.get("active", 1) == 0:
|
||||
continue
|
||||
return storage["storage"]
|
||||
return None
|
||||
|
||||
def _import_cloud_image(
|
||||
self,
|
||||
storage: str,
|
||||
filename: str,
|
||||
image_url: str,
|
||||
image_checksum: str | None,
|
||||
timeout: int,
|
||||
) -> str:
|
||||
"""Have Proxmox download *image_url* into *storage*; returns the volume id.
|
||||
|
||||
Proxmox runs the download as a task and verifies the checksum itself.
|
||||
A file already in the storage is reused — the name carries a hash of
|
||||
the full URL, and Proxmox only creates it once the download (and its
|
||||
checksum check) has succeeded, so an existing file is a complete one.
|
||||
"""
|
||||
volid = f"{storage}:import/{filename}"
|
||||
present = self._node_api().storage(storage).content.get(content="import")
|
||||
if any(item.get("volid") == volid for item in present):
|
||||
_logger.info(f"Cloud image already in {storage}: {volid}")
|
||||
return volid
|
||||
|
||||
params: dict[str, Any] = {"url": image_url, "content": "import", "filename": filename}
|
||||
if image_checksum:
|
||||
algo, expected = _split_checksum(image_checksum)
|
||||
params["checksum"] = expected
|
||||
params["checksum-algorithm"] = algo
|
||||
_logger.info(f"Downloading cloud image {image_url} into {volid}")
|
||||
upid = self._node_api().storage(storage)("download-url").post(**params)
|
||||
self._wait_for_task(upid, timeout=timeout)
|
||||
return volid
|
||||
|
||||
def _import_over_ssh(
|
||||
self,
|
||||
vmid: int,
|
||||
image_storage: str,
|
||||
image_url: str,
|
||||
image_checksum: str | None,
|
||||
download_timeout: int,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Download the image on the node and import it as the root disk.
|
||||
|
||||
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)
|
||||
_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",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Proxmox leaves the imported disk as an "unusedN" reference — find
|
||||
# it and attach it as the boot disk.
|
||||
imported_config = self._node_api().qemu(vmid).config.get()
|
||||
unused_value = next((v for k, v in imported_config.items() if k.startswith("unused")), None)
|
||||
if not unused_value:
|
||||
raise RuntimeError(
|
||||
f"Disk import for VM {vmid} did not produce an unused disk reference"
|
||||
)
|
||||
self._node_api().qemu(vmid).config.post(
|
||||
scsi0=f"{unused_value},discard=on",
|
||||
boot="order=scsi0",
|
||||
)
|
||||
|
||||
def _attach_root_disk(
|
||||
self,
|
||||
vmid: int,
|
||||
image_storage: str,
|
||||
image_url: str,
|
||||
image_checksum: str | None,
|
||||
download_timeout: int,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Get the cloud image onto the node and make it the VM's root disk.
|
||||
|
||||
Through an import storage when the node has one — Proxmox downloads,
|
||||
verifies and copies the image itself — and over SSH otherwise.
|
||||
"""
|
||||
import_storage = self._find_import_storage()
|
||||
filename = _import_volume_name(image_url) if import_storage else None
|
||||
if not import_storage or not filename:
|
||||
self._import_over_ssh(
|
||||
vmid, image_storage, image_url, image_checksum, download_timeout, timeout
|
||||
)
|
||||
return
|
||||
|
||||
volid = self._import_cloud_image(
|
||||
import_storage, filename, image_url, image_checksum, timeout=download_timeout
|
||||
)
|
||||
_logger.info(f"Importing {volid} into VM {vmid} on storage {image_storage}")
|
||||
upid = (
|
||||
self._node_api()
|
||||
.qemu(vmid)
|
||||
.config.post(
|
||||
scsi0=f"{image_storage}:0,import-from={volid},discard=on",
|
||||
boot="order=scsi0",
|
||||
)
|
||||
)
|
||||
if upid:
|
||||
self._wait_for_task(upid, timeout=timeout)
|
||||
|
||||
def _find_default_image_storage(self) -> str:
|
||||
"""Find a storage suitable for VM root disks (content includes 'images').
|
||||
|
||||
@@ -196,6 +396,56 @@ class ProxmoxVMProvisionMixin:
|
||||
)
|
||||
return targets
|
||||
|
||||
def get_vm_cpu_types(self) -> list[VMCpuTypeDict]:
|
||||
"""List the CPU models a new VM may be given, judged against this node's CPU."""
|
||||
return self._cpu_types_for(self._node_cpu_flags())
|
||||
|
||||
def _node_cpu_flags(self) -> set[str]:
|
||||
status = self._node_api().status.get() or {}
|
||||
return set(str((status.get("cpuinfo") or {}).get("flags", "")).split())
|
||||
|
||||
@staticmethod
|
||||
def _cpu_types_for(node_flags: set[str]) -> list[VMCpuTypeDict]:
|
||||
types: list[VMCpuTypeDict] = [
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"features": list(flags),
|
||||
"available": set(flags) <= node_flags,
|
||||
"default": name == _DEFAULT_CPU_TYPE,
|
||||
}
|
||||
for name, flags, description in _CPU_MODELS
|
||||
]
|
||||
types.append(
|
||||
{
|
||||
"name": "host",
|
||||
"description": _HOST_CPU_DESCRIPTION,
|
||||
"features": sorted(node_flags),
|
||||
"available": True,
|
||||
"default": False,
|
||||
}
|
||||
)
|
||||
return types
|
||||
|
||||
def _resolve_cpu_type(self, cpu_type: str | None) -> str:
|
||||
"""The model to create the VM with. A model asked for by name is checked
|
||||
against this node's CPU first: one it cannot run would fail only at VM
|
||||
start, after the disk import, leaving a half-built VM behind."""
|
||||
if cpu_type is None:
|
||||
return _DEFAULT_CPU_TYPE
|
||||
node_flags = self._node_cpu_flags()
|
||||
offered = {t["name"]: t for t in self._cpu_types_for(node_flags)}
|
||||
entry = offered.get(cpu_type)
|
||||
if entry is None:
|
||||
raise ValueError(f"Unknown CPU type {cpu_type!r}; choose one of {', '.join(offered)}")
|
||||
if not entry["available"]:
|
||||
missing = sorted(set(entry["features"]) - node_flags)
|
||||
raise ValueError(
|
||||
f"CPU type {cpu_type!r} needs {', '.join(missing)}, which the CPU of "
|
||||
f"node {self._node_name} does not have"
|
||||
)
|
||||
return cpu_type
|
||||
|
||||
def _wait_for_task(self, upid: str, timeout: int = 120) -> None:
|
||||
"""
|
||||
Poll a Proxmox task until completion.
|
||||
@@ -240,6 +490,7 @@ class ProxmoxVMProvisionMixin:
|
||||
ssh_public_keys: List[str] | None = None,
|
||||
disk_resize_gb: int | None = None,
|
||||
storage: str | None = None,
|
||||
cpu_type: str | None = None,
|
||||
download_timeout: int = 300,
|
||||
timeout: int = 180,
|
||||
) -> VMProvisionResultDict:
|
||||
@@ -272,6 +523,7 @@ class ProxmoxVMProvisionMixin:
|
||||
disk_resize_gb: resize root disk to this size (None = no resize)
|
||||
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)
|
||||
download_timeout: max seconds for the image download (skipped if cached)
|
||||
timeout: max seconds for the remaining provisioning steps
|
||||
|
||||
@@ -280,10 +532,13 @@ class ProxmoxVMProvisionMixin:
|
||||
|
||||
Raises:
|
||||
RuntimeError: provisioning failure (download, import, config, timeout, etc.)
|
||||
ValueError: invalid storage or configuration
|
||||
ValueError: invalid storage or configuration, or a cpu_type that is
|
||||
unknown or that this node's CPU cannot run (raised before
|
||||
anything is created)
|
||||
"""
|
||||
try:
|
||||
_logger.info(f"Creating VM '{name}' from image {image_url}")
|
||||
cpu_model = self._resolve_cpu_type(cpu_type)
|
||||
|
||||
# Step 1: Get next VMID
|
||||
next_vmid = self._api.cluster.nextid.get()
|
||||
@@ -297,6 +552,7 @@ class ProxmoxVMProvisionMixin:
|
||||
name=name,
|
||||
memory=memory,
|
||||
cores=cpu,
|
||||
cpu=cpu_model,
|
||||
ostype="l26",
|
||||
scsihw="virtio-scsi-pci",
|
||||
# Without this, Proxmox never attaches the virtio-serial
|
||||
@@ -306,30 +562,9 @@ class ProxmoxVMProvisionMixin:
|
||||
)
|
||||
|
||||
# Step 3: Download cloud image (cached) and import as root disk
|
||||
local_path = self._download_cloud_image(
|
||||
image_url, image_checksum, timeout=download_timeout
|
||||
)
|
||||
image_storage = storage or self._find_default_image_storage()
|
||||
|
||||
_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",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Proxmox leaves the imported disk as an "unusedN" reference — find
|
||||
# it and attach it as the boot disk.
|
||||
imported_config = self._node_api().qemu(vmid).config.get()
|
||||
unused_value = next(
|
||||
(v for k, v in imported_config.items() if k.startswith("unused")), None
|
||||
)
|
||||
if not unused_value:
|
||||
raise RuntimeError(
|
||||
f"Disk import for VM {vmid} did not produce an unused disk reference"
|
||||
)
|
||||
self._node_api().qemu(vmid).config.post(
|
||||
scsi0=f"{unused_value},discard=on",
|
||||
boot="order=scsi0",
|
||||
self._attach_root_disk(
|
||||
vmid, image_storage, image_url, image_checksum, download_timeout, timeout
|
||||
)
|
||||
|
||||
# Step 4: Configure network interfaces (CPU/memory already set at shell creation)
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ classifiers = [
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"napalm>=5.0.0",
|
||||
"napalm_device_types>=2.0.0",
|
||||
"napalm_device_types>=2.2.0",
|
||||
"paramiko>=5.0.0", # CVE-2026-44405; imported directly for SSH fallback (driver.py)
|
||||
"proxmoxer>=2.0.0",
|
||||
"netaddr>=0.9.0",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""`get_kernel_facts`: what the node's kernel has built and loaded.
|
||||
|
||||
A Proxmox node runs its own kernel under every guest, which makes it the host
|
||||
where a kernel CVE's preconditions matter most. The command and its parse are
|
||||
napalm-device-types'; the driver only carries the command over its exec path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_device_types import KernelFactsMixin
|
||||
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND
|
||||
from napalm_proxmox.driver import ProxmoxDriver
|
||||
|
||||
REPORT = (
|
||||
"[release]\n6.8.12-4-pve\n[loaded]\nkvm_intel\n[builtin]\nkernel/net/ipv4/tcp_cubic.ko\n"
|
||||
"[available]\nkernel/net/tipc/tipc.ko\n[config]\nCONFIG_TIPC=m\n"
|
||||
)
|
||||
WIRE = "KFACTS_BEGIN\n" + base64.encodebytes(gzip.compress(REPORT.encode())).decode() + "KFACTS_END"
|
||||
|
||||
|
||||
def test_the_driver_declares_the_contract():
|
||||
assert issubclass(ProxmoxDriver, KernelFactsMixin)
|
||||
|
||||
|
||||
def test_it_runs_the_shared_command(driver):
|
||||
with patch.object(driver, "_exec_ssh_command", return_value=WIRE) as exec_:
|
||||
facts = driver.get_kernel_facts()
|
||||
|
||||
exec_.assert_called_once_with(KERNEL_FACTS_COMMAND)
|
||||
assert facts["release"] == "6.8.12-4-pve"
|
||||
assert facts["loaded"] == ["kvm_intel"]
|
||||
assert facts["builtin"] == ["tcp_cubic"]
|
||||
assert facts["available"] == ["tipc"]
|
||||
assert facts["config"] == {"CONFIG_TIPC": "m"}
|
||||
|
||||
|
||||
def test_output_without_a_report_raises(driver):
|
||||
with patch.object(driver, "_exec_ssh_command", return_value=""):
|
||||
with pytest.raises(ValueError):
|
||||
driver.get_kernel_facts()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Services on a Proxmox node: systemd, through napalm-device-types' mixin.
|
||||
|
||||
The listing used to read ``list-unit-files``' second column, which since
|
||||
systemd 245 is followed by a preset column -- so ``enabled`` was false for
|
||||
every service on every node (#6). An action ended in ``|| true`` and reported
|
||||
success whatever happened. Both now come from the shared mixin; the driver
|
||||
only carries the command over its exec path, as root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from napalm_device_types import SystemdServicesMixin
|
||||
from napalm_device_types.systemd import SYSTEMD_SERVICES_COMMAND, service_action_command
|
||||
|
||||
from napalm_proxmox.driver import ProxmoxDriver
|
||||
|
||||
REPORT = (
|
||||
"SVC_BEGIN\n[files]\npveproxy.service enabled enabled\n[units]\n"
|
||||
"MainPID=1234\nId=pveproxy.service\nNames=pveproxy.service\nLoadState=loaded\n"
|
||||
"ActiveState=active\nSubState=running\nUnitFileState=enabled\n"
|
||||
"[generated]\nSVC_END\n"
|
||||
)
|
||||
|
||||
|
||||
def test_the_driver_uses_the_shared_mixin():
|
||||
assert issubclass(ProxmoxDriver, SystemdServicesMixin)
|
||||
assert ProxmoxDriver.get_services is SystemdServicesMixin.get_services
|
||||
assert ProxmoxDriver.manage_service is SystemdServicesMixin.manage_service
|
||||
|
||||
|
||||
def test_listing_runs_the_shared_command(driver):
|
||||
with patch.object(driver, "_exec_ssh_command", return_value=REPORT) as exec_:
|
||||
services = driver.get_services()
|
||||
|
||||
exec_.assert_called_once_with(SYSTEMD_SERVICES_COMMAND)
|
||||
assert services == [{"name": "pveproxy", "running": True, "enabled": True, "pid": 1234}]
|
||||
|
||||
|
||||
def test_an_unreadable_listing_raises_instead_of_reporting_no_services(driver):
|
||||
with patch.object(driver, "_exec_ssh_command", return_value=""):
|
||||
with pytest.raises(ValueError):
|
||||
driver.get_services()
|
||||
|
||||
|
||||
def test_an_action_reports_its_real_outcome(driver):
|
||||
failed = "Failed to restart nope.service: Unit nope.service not found.\n__SVC_RC=5"
|
||||
with patch.object(driver, "_exec_ssh_command", return_value=failed) as exec_:
|
||||
result = driver.manage_service("nope", "restart")
|
||||
|
||||
exec_.assert_called_once_with(service_action_command("nope", "restart"))
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["output"]
|
||||
|
||||
|
||||
def test_a_successful_action(driver):
|
||||
with patch.object(driver, "_exec_ssh_command", return_value="__SVC_RC=0"):
|
||||
assert driver.manage_service("pveproxy", "restart") == {"success": True, "output": ""}
|
||||
|
||||
|
||||
def test_an_invalid_name_never_reaches_the_node(driver):
|
||||
with patch.object(driver, "_exec_ssh_command") as exec_:
|
||||
with pytest.raises(ValueError):
|
||||
driver.manage_service("pveproxy; reboot", "stop")
|
||||
|
||||
exec_.assert_not_called()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Which virtual CPU model a new VM gets.
|
||||
|
||||
Created without a ``cpu`` argument, a Proxmox VM falls back to ``kvm64``:
|
||||
no AVX, no AES-NI. MongoDB 5.0 and later will not even start on it, which is
|
||||
how a Graylog provisioned through netOrk failed (netOrk#494). The driver now
|
||||
always names a model, defaulting to ``x86-64-v2-AES`` as the Proxmox GUI does,
|
||||
and lists the alternatives with what each needs from the node's CPU.
|
||||
|
||||
The flag sets below are trimmed from real ``/nodes/{node}/status`` answers in a
|
||||
mixed cluster: a Celeron J3455 (no AVX at all) and an i7-7700 (AVX2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin
|
||||
|
||||
CELERON_J3455 = (
|
||||
"fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush "
|
||||
"mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc pni pclmulqdq "
|
||||
"ssse3 cx16 sse4_1 sse4_2 x2apic movbe popcnt aes rdrand lahf_lm 3dnowprefetch "
|
||||
"erms mpx rdseed smap clflushopt sha_ni xsaveopt xsavec xgetbv1"
|
||||
)
|
||||
CORE_I7_7700 = (
|
||||
"fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush "
|
||||
"mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc pni pclmulqdq "
|
||||
"ssse3 fma cx16 sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand "
|
||||
"lahf_lm abm 3dnowprefetch fsgsbase bmi1 hle avx2 smep bmi2 erms invpcid rtm mpx "
|
||||
"rdseed adx smap clflushopt xsaveopt xsavec xgetbv1 xsaves"
|
||||
)
|
||||
|
||||
|
||||
def _mixin_on(flags: str) -> tuple[ProxmoxVMProvisionMixin, MagicMock, MagicMock]:
|
||||
"""A mixin whose node reports `flags` and that can run a full create."""
|
||||
mixin = ProxmoxVMProvisionMixin()
|
||||
mixin._node_name = "pve1"
|
||||
|
||||
api = MagicMock()
|
||||
api.cluster.nextid.get.return_value = 120
|
||||
api.storage.return_value.get.return_value = {"path": "/var/lib/vz"}
|
||||
|
||||
node = MagicMock()
|
||||
node.status.get.return_value = {"cpuinfo": {"model": "test", "flags": flags}}
|
||||
node.storage.get.return_value = [
|
||||
{"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "enabled": 1},
|
||||
{"storage": "snippets", "type": "dir", "content": "snippets", "enabled": 1},
|
||||
]
|
||||
vm = MagicMock()
|
||||
vm.config.get.return_value = {
|
||||
"unused0": "local-lvm:vm-120-disk-0",
|
||||
"scsi0": "local-lvm:vm-120-disk-0",
|
||||
}
|
||||
vm.status.start.post.return_value = "UPID:pve1:1:start"
|
||||
node.qemu.return_value = vm
|
||||
task = MagicMock()
|
||||
task.status.get.return_value = {"status": "stopped", "exitstatus": "OK"}
|
||||
node.tasks.return_value = task
|
||||
|
||||
mixin._api = api
|
||||
mixin._node_api = MagicMock(return_value=node)
|
||||
mixin._download_cloud_image = MagicMock(return_value="/var/lib/vz/template/x.qcow2")
|
||||
mixin._run_node_command = MagicMock(return_value="")
|
||||
return mixin, api, node
|
||||
|
||||
|
||||
def _create(mixin: ProxmoxVMProvisionMixin, **kwargs):
|
||||
with patch("time.sleep"):
|
||||
return mixin.create_vm_from_cloud_init(
|
||||
name="graylog-01",
|
||||
image_url="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
|
||||
cpu=2,
|
||||
memory=4096,
|
||||
nics=[{"bridge": "vmbr0"}],
|
||||
cloud_init_config={"hostname": "graylog-01"},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _by_name(types):
|
||||
return {t["name"]: t for t in types}
|
||||
|
||||
|
||||
class TestListing:
|
||||
def test_offers_the_three_models_in_order(self):
|
||||
mixin, _, _ = _mixin_on(CORE_I7_7700)
|
||||
assert [t["name"] for t in mixin.get_vm_cpu_types()] == [
|
||||
"x86-64-v2-AES",
|
||||
"x86-64-v3",
|
||||
"host",
|
||||
]
|
||||
|
||||
def test_exactly_one_default_and_it_is_what_the_gui_uses(self):
|
||||
mixin, _, _ = _mixin_on(CORE_I7_7700)
|
||||
defaults = [t["name"] for t in mixin.get_vm_cpu_types() if t["default"]]
|
||||
assert defaults == ["x86-64-v2-AES"]
|
||||
|
||||
def test_v3_gives_avx_and_runs_on_a_core_i7(self):
|
||||
mixin, _, _ = _mixin_on(CORE_I7_7700)
|
||||
v3 = _by_name(mixin.get_vm_cpu_types())["x86-64-v3"]
|
||||
assert v3["available"] is True
|
||||
assert {"avx", "avx2"} <= set(v3["features"])
|
||||
|
||||
def test_v3_is_unavailable_on_a_celeron_without_avx(self):
|
||||
mixin, _, _ = _mixin_on(CELERON_J3455)
|
||||
types = _by_name(mixin.get_vm_cpu_types())
|
||||
assert types["x86-64-v3"]["available"] is False
|
||||
assert types["x86-64-v2-AES"]["available"] is True
|
||||
|
||||
def test_v2_aes_has_no_avx(self):
|
||||
mixin, _, _ = _mixin_on(CORE_I7_7700)
|
||||
assert "avx" not in _by_name(mixin.get_vm_cpu_types())["x86-64-v2-AES"]["features"]
|
||||
|
||||
def test_host_passes_the_nodes_own_flags_through(self):
|
||||
"""On a CPU without AVX, `host` gives none either -- a role that needs
|
||||
AVX must not be told `host` would help there."""
|
||||
mixin, _, _ = _mixin_on(CELERON_J3455)
|
||||
host = _by_name(mixin.get_vm_cpu_types())["host"]
|
||||
assert host["available"] is True
|
||||
assert "avx" not in host["features"]
|
||||
assert "aes" in host["features"]
|
||||
|
||||
def test_every_entry_explains_itself(self):
|
||||
mixin, _, _ = _mixin_on(CORE_I7_7700)
|
||||
assert all(t["description"] for t in mixin.get_vm_cpu_types())
|
||||
|
||||
|
||||
class TestCreate:
|
||||
def test_names_the_default_model_instead_of_leaving_kvm64(self):
|
||||
mixin, _, node = _mixin_on(CORE_I7_7700)
|
||||
_create(mixin)
|
||||
assert node.qemu.post.call_args[1]["cpu"] == "x86-64-v2-AES"
|
||||
|
||||
def test_passes_a_chosen_model_through(self):
|
||||
mixin, _, node = _mixin_on(CORE_I7_7700)
|
||||
_create(mixin, cpu_type="host")
|
||||
assert node.qemu.post.call_args[1]["cpu"] == "host"
|
||||
|
||||
def test_refuses_a_model_the_node_cannot_run_before_creating_anything(self):
|
||||
mixin, api, node = _mixin_on(CELERON_J3455)
|
||||
with pytest.raises(ValueError, match="avx"):
|
||||
_create(mixin, cpu_type="x86-64-v3")
|
||||
api.cluster.nextid.get.assert_not_called()
|
||||
node.qemu.post.assert_not_called()
|
||||
|
||||
def test_refuses_an_unknown_model_before_creating_anything(self):
|
||||
mixin, api, node = _mixin_on(CORE_I7_7700)
|
||||
with pytest.raises(ValueError, match="kvm64"):
|
||||
_create(mixin, cpu_type="kvm64")
|
||||
api.cluster.nextid.get.assert_not_called()
|
||||
node.qemu.post.assert_not_called()
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Tests for provisioning through a storage with content type "import".
|
||||
|
||||
Proxmox (8.2+) can download a disk image into such a storage itself
|
||||
(``download-url``, with checksum verification) and attach it to a VM with
|
||||
``import-from``. When the node has one, provisioning uses it instead of
|
||||
downloading over SSH into the node's root filesystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from napalm_proxmox.vm_provision_mixin import (
|
||||
ProxmoxVMProvisionMixin,
|
||||
_import_volume_name,
|
||||
)
|
||||
|
||||
_UBUNTU = (
|
||||
"https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.img"
|
||||
)
|
||||
_DEBIAN = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
|
||||
|
||||
|
||||
def _mixin_with_storages(storages: list[dict]) -> tuple[ProxmoxVMProvisionMixin, MagicMock]:
|
||||
mixin = ProxmoxVMProvisionMixin()
|
||||
mixin._node_name = "pve1"
|
||||
node = MagicMock()
|
||||
node.storage.get.return_value = storages
|
||||
mixin._node_api = MagicMock(return_value=node)
|
||||
return mixin, node
|
||||
|
||||
|
||||
# ── which storage ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_find_import_storage_picks_a_storage_that_accepts_import():
|
||||
mixin, _ = _mixin_with_storages(
|
||||
[
|
||||
{"storage": "local", "content": "iso,backup,vztmpl,snippets"},
|
||||
{"storage": "software", "content": "import,iso", "active": 1},
|
||||
]
|
||||
)
|
||||
assert mixin._find_import_storage() == "software"
|
||||
|
||||
|
||||
def test_find_import_storage_does_not_mistake_images_for_import():
|
||||
mixin, _ = _mixin_with_storages([{"storage": "local-zfs", "content": "images,rootdir"}])
|
||||
assert mixin._find_import_storage() is None
|
||||
|
||||
|
||||
def test_find_import_storage_skips_disabled_and_inactive_storages():
|
||||
"""An inactive storage is typically an unmounted share; Proxmox cannot
|
||||
download into it, and the SSH path still works without it."""
|
||||
mixin, _ = _mixin_with_storages(
|
||||
[
|
||||
{"storage": "off", "content": "import", "enabled": 0},
|
||||
{"storage": "unmounted", "content": "import", "active": 0},
|
||||
]
|
||||
)
|
||||
assert mixin._find_import_storage() is None
|
||||
|
||||
|
||||
# ── what the file is called ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_import_volume_name_keeps_a_qcow2_extension():
|
||||
name = _import_volume_name(_DEBIAN)
|
||||
assert name is not None
|
||||
assert name.endswith("-debian-12-genericcloud-amd64.qcow2")
|
||||
|
||||
|
||||
def test_import_volume_name_stores_an_img_as_qcow2():
|
||||
"""Proxmox reads the format off the extension and does not accept .img.
|
||||
Ubuntu's .img is qcow2; guessing qcow2 for a raw .img fails loudly at
|
||||
import, while the opposite guess would import a qcow2 container as a raw
|
||||
disk without complaint."""
|
||||
name = _import_volume_name(_UBUNTU)
|
||||
assert name is not None
|
||||
assert name.endswith("-ubuntu-26.04-server-cloudimg-amd64.qcow2")
|
||||
|
||||
|
||||
def test_import_volume_name_is_none_for_types_proxmox_cannot_import():
|
||||
assert _import_volume_name("https://example.org/image.qcow2.xz") is None
|
||||
assert _import_volume_name("https://example.org/installer.iso") is None
|
||||
assert _import_volume_name("https://example.org/noextension") is None
|
||||
|
||||
|
||||
def test_import_volume_name_differs_for_the_same_basename_under_another_url():
|
||||
"""Ubuntu publishes daily builds under one basename; a cache keyed on the
|
||||
basename alone would serve yesterday's build."""
|
||||
a = _import_volume_name("https://x/release-20260927/ubuntu-26.04-server-cloudimg-amd64.img")
|
||||
b = _import_volume_name("https://x/release-20260928/ubuntu-26.04-server-cloudimg-amd64.img")
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_import_volume_name_uses_only_characters_proxmox_keeps():
|
||||
name = _import_volume_name("https://x/My Image (beta)+1.qcow2")
|
||||
assert name is not None
|
||||
assert all(c.isalnum() or c in "-.+=_" for c in name)
|
||||
|
||||
|
||||
# ── the download ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_import_cloud_image_reuses_a_file_already_in_the_storage():
|
||||
mixin, node = _mixin_with_storages([])
|
||||
name = _import_volume_name(_DEBIAN)
|
||||
node.storage.return_value.content.get.return_value = [
|
||||
{"volid": f"software:import/{name}", "content": "import"}
|
||||
]
|
||||
|
||||
volid = mixin._import_cloud_image("software", name, _DEBIAN, None, timeout=300)
|
||||
|
||||
assert volid == f"software:import/{name}"
|
||||
node.storage.return_value.return_value.post.assert_not_called()
|
||||
|
||||
|
||||
def test_import_cloud_image_has_proxmox_download_and_verify_it():
|
||||
mixin, node = _mixin_with_storages([])
|
||||
name = _import_volume_name(_UBUNTU)
|
||||
node.storage.return_value.content.get.return_value = []
|
||||
download = node.storage.return_value.return_value
|
||||
download.post.return_value = "UPID:pve1:download"
|
||||
mixin._wait_for_task = MagicMock()
|
||||
|
||||
volid = mixin._import_cloud_image("software", name, _UBUNTU, "sha256:abc123", timeout=300)
|
||||
|
||||
assert volid == f"software:import/{name}"
|
||||
node.storage.assert_any_call("software")
|
||||
node.storage.return_value.assert_called_with("download-url")
|
||||
download.post.assert_called_once_with(
|
||||
url=_UBUNTU,
|
||||
content="import",
|
||||
filename=name,
|
||||
checksum="abc123",
|
||||
**{"checksum-algorithm": "sha256"},
|
||||
)
|
||||
mixin._wait_for_task.assert_called_once_with("UPID:pve1:download", timeout=300)
|
||||
|
||||
|
||||
def test_import_cloud_image_without_checksum_sends_none():
|
||||
mixin, node = _mixin_with_storages([])
|
||||
name = _import_volume_name(_DEBIAN)
|
||||
node.storage.return_value.content.get.return_value = []
|
||||
mixin._wait_for_task = MagicMock()
|
||||
|
||||
mixin._import_cloud_image("software", name, _DEBIAN, None, timeout=300)
|
||||
|
||||
kwargs = node.storage.return_value.return_value.post.call_args.kwargs
|
||||
assert "checksum" not in kwargs
|
||||
assert "checksum-algorithm" not in kwargs
|
||||
|
||||
|
||||
# ── provisioning end to end ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _provisioning_mixin(storages: list[dict]) -> tuple[ProxmoxVMProvisionMixin, MagicMock]:
|
||||
mixin, node = _mixin_with_storages(storages)
|
||||
api = MagicMock()
|
||||
api.cluster.nextid.get.return_value = 101
|
||||
api.storage.return_value.get.return_value = {"path": "/var/lib/vz"}
|
||||
mixin._api = api
|
||||
mixin._run_node_command = MagicMock(return_value="")
|
||||
mixin._download_cloud_image = MagicMock(return_value="/var/lib/vz/template/x.qcow2")
|
||||
vm = MagicMock()
|
||||
node.qemu.return_value = vm
|
||||
vm.config.get.return_value = {"unused0": "local-zfs:vm-101-disk-0"}
|
||||
vm.config.post.return_value = None
|
||||
node.tasks.return_value.status.get.return_value = {"status": "stopped", "exitstatus": "OK"}
|
||||
node.storage.return_value.content.get.return_value = []
|
||||
return mixin, node
|
||||
|
||||
|
||||
def _provision(mixin: ProxmoxVMProvisionMixin, image_url: str) -> None:
|
||||
with patch("time.sleep"):
|
||||
mixin.create_vm_from_cloud_init(
|
||||
name="vm",
|
||||
image_url=image_url,
|
||||
cpu=1,
|
||||
memory=1024,
|
||||
nics=[{"bridge": "vmbr0"}],
|
||||
cloud_init_config={"hostname": "vm"},
|
||||
storage="local-zfs",
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
|
||||
_WITH_IMPORT = [
|
||||
{"storage": "local", "content": "iso,backup,vztmpl,snippets"},
|
||||
{"storage": "software", "content": "import,iso"},
|
||||
{"storage": "local-zfs", "content": "images,rootdir"},
|
||||
]
|
||||
|
||||
|
||||
def test_provisioning_downloads_through_the_import_storage_when_there_is_one():
|
||||
mixin, node = _provisioning_mixin(_WITH_IMPORT)
|
||||
name = _import_volume_name(_UBUNTU)
|
||||
|
||||
_provision(mixin, _UBUNTU)
|
||||
|
||||
mixin._download_cloud_image.assert_not_called()
|
||||
assert not any("importdisk" in c.args[0] for c in mixin._run_node_command.call_args_list), (
|
||||
"no SSH download/import when Proxmox can do it"
|
||||
)
|
||||
disk = next(
|
||||
c.kwargs for c in node.qemu.return_value.config.post.call_args_list if "scsi0" in c.kwargs
|
||||
)
|
||||
assert disk["scsi0"] == f"local-zfs:0,import-from=software:import/{name},discard=on"
|
||||
assert disk["boot"] == "order=scsi0"
|
||||
|
||||
|
||||
def test_provisioning_waits_for_the_import_task():
|
||||
"""Attaching with import-from copies the image — a task, which has to
|
||||
finish before the cloud-init drive and the resize touch the VM."""
|
||||
mixin, node = _provisioning_mixin(_WITH_IMPORT)
|
||||
node.qemu.return_value.config.post.side_effect = lambda **kw: (
|
||||
"UPID:pve1:import" if "scsi0" in kw else None
|
||||
)
|
||||
mixin._wait_for_task = MagicMock()
|
||||
|
||||
_provision(mixin, _DEBIAN)
|
||||
|
||||
waited = [c.args[0] for c in mixin._wait_for_task.call_args_list]
|
||||
assert "UPID:pve1:import" in waited
|
||||
|
||||
|
||||
def test_provisioning_falls_back_to_ssh_without_an_import_storage():
|
||||
mixin, _ = _provisioning_mixin([s for s in _WITH_IMPORT if s["storage"] != "software"])
|
||||
|
||||
_provision(mixin, _UBUNTU)
|
||||
|
||||
mixin._download_cloud_image.assert_called_once()
|
||||
assert any("qm importdisk 101" in c.args[0] for c in mixin._run_node_command.call_args_list)
|
||||
|
||||
|
||||
def test_provisioning_falls_back_to_ssh_for_an_image_type_proxmox_cannot_import():
|
||||
mixin, _ = _provisioning_mixin(_WITH_IMPORT)
|
||||
|
||||
_provision(mixin, "https://example.org/image.qcow2.xz")
|
||||
|
||||
mixin._download_cloud_image.assert_called_once()
|
||||
Reference in New Issue
Block a user